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:
김보곤
2026-03-19 08:36:45 +09:00
parent d8a57f71c6
commit 847c60b03d
9 changed files with 826 additions and 0 deletions

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