- 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)
330 lines
11 KiB
PHP
330 lines
11 KiB
PHP
<?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'));
|
|
}
|
|
}
|
|
}
|
|
}
|