feat: [material] 부적합관리 API Phase 1-A 구현
- Migration: nonconforming_reports, nonconforming_report_items 테이블 - Model: NonconformingReport, NonconformingReportItem (관계, cast, scope) - FormRequest: Store/Update 검증 (items 배열 포함) - Service: CRUD + 채번(NC-YYYYMMDD-NNN) + 비용 자동 계산 + 상태 전이 - Controller: REST 7개 엔드포인트 (목록/통계/상세/등록/수정/삭제/상태변경) - Route: /api/v1/material/nonconforming-reports - i18n: 부적합관리 에러 메시지 (ko)
This commit is contained in:
329
app/Services/NonconformingReportService.php
Normal file
329
app/Services/NonconformingReportService.php
Normal file
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Materials\NonconformingReport;
|
||||
use App\Models\Materials\NonconformingReportItem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NonconformingReportService extends Service
|
||||
{
|
||||
/**
|
||||
* 목록 조회 (필터/검색/페이지네이션)
|
||||
*/
|
||||
public function index(array $params): mixed
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$perPage = $params['per_page'] ?? 20;
|
||||
$page = $params['page'] ?? 1;
|
||||
|
||||
$query = NonconformingReport::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['creator:id,name', 'item:id,name', 'order:id,order_number']);
|
||||
|
||||
// 필터
|
||||
if (! empty($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
if (! empty($params['nc_type'])) {
|
||||
$query->where('nc_type', $params['nc_type']);
|
||||
}
|
||||
if (! empty($params['from_date'])) {
|
||||
$query->where('occurred_at', '>=', $params['from_date']);
|
||||
}
|
||||
if (! empty($params['to_date'])) {
|
||||
$query->where('occurred_at', '<=', $params['to_date']);
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (! empty($params['search'])) {
|
||||
$search = $params['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('nc_number', 'like', "%{$search}%")
|
||||
->orWhere('site_name', 'like', "%{$search}%")
|
||||
->orWhereHas('item', function ($q2) use ($search) {
|
||||
$q2->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$query->orderByDesc('occurred_at')->orderByDesc('id');
|
||||
|
||||
return $query->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 조회
|
||||
*/
|
||||
public function show(int $id): NonconformingReport
|
||||
{
|
||||
return NonconformingReport::query()
|
||||
->where('tenant_id', $this->tenantId())
|
||||
->with([
|
||||
'items',
|
||||
'order:id,order_number,site_name',
|
||||
'item:id,name',
|
||||
'department:id,name',
|
||||
'creator:id,name',
|
||||
'actionManager:id,name',
|
||||
'relatedEmployee:id,name',
|
||||
'files',
|
||||
])
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록 (items 일괄 저장)
|
||||
*/
|
||||
public function store(array $data): NonconformingReport
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
||||
// 채번
|
||||
$ncNumber = $this->generateNcNumber($tenantId);
|
||||
|
||||
// 비용 계산
|
||||
$items = $data['items'] ?? [];
|
||||
$materialCost = $data['material_cost'] ?? $this->sumItemAmounts($items);
|
||||
$shippingCost = $data['shipping_cost'] ?? 0;
|
||||
$constructionCost = $data['construction_cost'] ?? 0;
|
||||
$otherCost = $data['other_cost'] ?? 0;
|
||||
$totalCost = $materialCost + $shippingCost + $constructionCost + $otherCost;
|
||||
|
||||
$report = NonconformingReport::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'nc_number' => $ncNumber,
|
||||
'status' => NonconformingReport::STATUS_RECEIVED,
|
||||
'nc_type' => $data['nc_type'],
|
||||
'occurred_at' => $data['occurred_at'],
|
||||
'confirmed_at' => $data['confirmed_at'] ?? null,
|
||||
'site_name' => $data['site_name'] ?? null,
|
||||
'department_id' => $data['department_id'] ?? null,
|
||||
'order_id' => $data['order_id'] ?? null,
|
||||
'item_id' => $data['item_id'] ?? null,
|
||||
'defect_quantity' => $data['defect_quantity'] ?? null,
|
||||
'unit' => $data['unit'] ?? null,
|
||||
'defect_description' => $data['defect_description'] ?? null,
|
||||
'cause_analysis' => $data['cause_analysis'] ?? null,
|
||||
'corrective_action' => $data['corrective_action'] ?? null,
|
||||
'action_completed_at' => $data['action_completed_at'] ?? null,
|
||||
'action_manager_id' => $data['action_manager_id'] ?? null,
|
||||
'related_employee_id' => $data['related_employee_id'] ?? null,
|
||||
'material_cost' => $materialCost,
|
||||
'shipping_cost' => $shippingCost,
|
||||
'construction_cost' => $constructionCost,
|
||||
'other_cost' => $otherCost,
|
||||
'total_cost' => $totalCost,
|
||||
'remarks' => $data['remarks'] ?? null,
|
||||
'drawing_location' => $data['drawing_location'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
// 자재 상세 내역 저장
|
||||
$this->syncItems($report, $items, $tenantId);
|
||||
|
||||
return $this->show($report->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 (items sync)
|
||||
*/
|
||||
public function update(int $id, array $data): NonconformingReport
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
|
||||
$report = NonconformingReport::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
if ($report->isClosed()) {
|
||||
abort(403, __('error.nonconforming.closed_cannot_edit'));
|
||||
}
|
||||
|
||||
// items가 전달되면 자재비 재계산
|
||||
$hasItems = array_key_exists('items', $data);
|
||||
if ($hasItems) {
|
||||
$this->syncItems($report, $data['items'] ?? [], $tenantId);
|
||||
$data['material_cost'] = (int) $report->items()->sum('amount');
|
||||
}
|
||||
|
||||
// 비용 합계 재계산
|
||||
$materialCost = $data['material_cost'] ?? $report->material_cost;
|
||||
$shippingCost = $data['shipping_cost'] ?? $report->shipping_cost;
|
||||
$constructionCost = $data['construction_cost'] ?? $report->construction_cost;
|
||||
$otherCost = $data['other_cost'] ?? $report->other_cost;
|
||||
$data['total_cost'] = $materialCost + $shippingCost + $constructionCost + $otherCost;
|
||||
$data['updated_by'] = $userId;
|
||||
|
||||
// items 키는 모델 필드가 아니므로 제거
|
||||
unset($data['items']);
|
||||
|
||||
$report->update($data);
|
||||
|
||||
return $this->show($report->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 (소프트)
|
||||
*/
|
||||
public function destroy(int $id): void
|
||||
{
|
||||
$report = NonconformingReport::query()
|
||||
->where('tenant_id', $this->tenantId())
|
||||
->findOrFail($id);
|
||||
|
||||
if ($report->isClosed()) {
|
||||
abort(403, __('error.nonconforming.closed_cannot_delete'));
|
||||
}
|
||||
|
||||
$report->deleted_by = $this->apiUserId();
|
||||
$report->save();
|
||||
$report->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 변경
|
||||
*/
|
||||
public function changeStatus(int $id, string $newStatus): NonconformingReport
|
||||
{
|
||||
$report = NonconformingReport::query()
|
||||
->where('tenant_id', $this->tenantId())
|
||||
->findOrFail($id);
|
||||
|
||||
$this->validateStatusTransition($report, $newStatus);
|
||||
|
||||
$report->status = $newStatus;
|
||||
$report->updated_by = $this->apiUserId();
|
||||
$report->save();
|
||||
|
||||
return $this->show($report->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태별 통계
|
||||
*/
|
||||
public function stats(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$statusCounts = NonconformingReport::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray();
|
||||
|
||||
$totalCost = NonconformingReport::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->sum('total_cost');
|
||||
|
||||
return [
|
||||
'by_status' => [
|
||||
'RECEIVED' => $statusCounts['RECEIVED'] ?? 0,
|
||||
'ANALYZING' => $statusCounts['ANALYZING'] ?? 0,
|
||||
'RESOLVED' => $statusCounts['RESOLVED'] ?? 0,
|
||||
'CLOSED' => $statusCounts['CLOSED'] ?? 0,
|
||||
],
|
||||
'total_count' => array_sum($statusCounts),
|
||||
'total_cost' => (int) $totalCost,
|
||||
];
|
||||
}
|
||||
|
||||
// ── private ──
|
||||
|
||||
/**
|
||||
* 채번: NC-YYYYMMDD-NNN
|
||||
*/
|
||||
private function generateNcNumber(int $tenantId): string
|
||||
{
|
||||
$prefix = 'NC';
|
||||
$date = now()->format('Ymd');
|
||||
$pattern = "{$prefix}-{$date}-";
|
||||
|
||||
$lastNumber = NonconformingReport::withTrashed()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('nc_number', 'like', "{$pattern}%")
|
||||
->orderByDesc('nc_number')
|
||||
->value('nc_number');
|
||||
|
||||
$seq = $lastNumber ? ((int) substr($lastNumber, -3) + 1) : 1;
|
||||
|
||||
return sprintf('%s-%s-%03d', $prefix, $date, $seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 상세 내역 동기화 (삭제 후 재생성)
|
||||
*/
|
||||
private function syncItems(NonconformingReport $report, array $items, int $tenantId): void
|
||||
{
|
||||
$report->items()->delete();
|
||||
|
||||
foreach ($items as $index => $itemData) {
|
||||
$quantity = $itemData['quantity'] ?? 0;
|
||||
$unitPrice = $itemData['unit_price'] ?? 0;
|
||||
|
||||
NonconformingReportItem::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'nonconforming_report_id' => $report->id,
|
||||
'item_id' => $itemData['item_id'] ?? null,
|
||||
'item_name' => $itemData['item_name'],
|
||||
'specification' => $itemData['specification'] ?? null,
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => $unitPrice,
|
||||
'amount' => (int) ($quantity * $unitPrice),
|
||||
'sort_order' => $index,
|
||||
'remarks' => $itemData['remarks'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* items 배열에서 금액 합계 계산
|
||||
*/
|
||||
private function sumItemAmounts(array $items): int
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($items as $item) {
|
||||
$qty = $item['quantity'] ?? 0;
|
||||
$price = $item['unit_price'] ?? 0;
|
||||
$total += (int) ($qty * $price);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 전이 검증
|
||||
*/
|
||||
private function validateStatusTransition(NonconformingReport $report, string $newStatus): void
|
||||
{
|
||||
$allowed = [
|
||||
NonconformingReport::STATUS_RECEIVED => [NonconformingReport::STATUS_ANALYZING],
|
||||
NonconformingReport::STATUS_ANALYZING => [NonconformingReport::STATUS_RESOLVED],
|
||||
NonconformingReport::STATUS_RESOLVED => [NonconformingReport::STATUS_CLOSED],
|
||||
NonconformingReport::STATUS_CLOSED => [],
|
||||
];
|
||||
|
||||
$current = $report->status;
|
||||
|
||||
if (! in_array($newStatus, $allowed[$current] ?? [])) {
|
||||
abort(422, __('error.nonconforming.invalid_status_transition', [
|
||||
'from' => NonconformingReport::STATUSES[$current] ?? $current,
|
||||
'to' => NonconformingReport::STATUSES[$newStatus] ?? $newStatus,
|
||||
]));
|
||||
}
|
||||
|
||||
// ANALYZING → RESOLVED: 원인분석 + 시정조치 필수
|
||||
if ($newStatus === NonconformingReport::STATUS_RESOLVED) {
|
||||
if (empty($report->cause_analysis) || empty($report->corrective_action)) {
|
||||
abort(422, __('error.nonconforming.analysis_required'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user