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:
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Material\StoreNonconformingReportRequest;
|
||||
use App\Http\Requests\Material\UpdateNonconformingReportRequest;
|
||||
use App\Services\NonconformingReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NonconformingReportController extends Controller
|
||||
{
|
||||
public function __construct(private NonconformingReportService $service) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function stats(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->stats($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function store(StoreNonconformingReportRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->store($request->validated());
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
public function update(UpdateNonconformingReportRequest $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->update($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->service->destroy($id);
|
||||
|
||||
return 'success';
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
public function changeStatus(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate(['status' => 'required|string|in:RECEIVED,ANALYZING,RESOLVED,CLOSED']);
|
||||
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->changeStatus($id, $request->input('status'));
|
||||
}, __('message.updated'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Material;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreNonconformingReportRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nc_type' => 'required|string|in:material,process,construction,other',
|
||||
'occurred_at' => 'required|date',
|
||||
'confirmed_at' => 'nullable|date',
|
||||
'site_name' => 'nullable|string|max:100',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'order_id' => 'nullable|integer|exists:orders,id',
|
||||
'item_id' => 'nullable|integer|exists:items,id',
|
||||
'defect_quantity' => 'nullable|numeric|min:0',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'defect_description' => 'nullable|string',
|
||||
'cause_analysis' => 'nullable|string',
|
||||
'corrective_action' => 'nullable|string',
|
||||
'action_completed_at' => 'nullable|date',
|
||||
'action_manager_id' => 'nullable|integer',
|
||||
'related_employee_id' => 'nullable|integer',
|
||||
'material_cost' => 'nullable|integer|min:0',
|
||||
'shipping_cost' => 'nullable|integer|min:0',
|
||||
'construction_cost' => 'nullable|integer|min:0',
|
||||
'other_cost' => 'nullable|integer|min:0',
|
||||
'remarks' => 'nullable|string',
|
||||
'drawing_location' => 'nullable|string|max:255',
|
||||
|
||||
// 자재 상세 내역
|
||||
'items' => 'nullable|array',
|
||||
'items.*.item_id' => 'nullable|integer',
|
||||
'items.*.item_name' => 'required_with:items|string|max:100',
|
||||
'items.*.specification' => 'nullable|string|max:100',
|
||||
'items.*.quantity' => 'nullable|numeric|min:0',
|
||||
'items.*.unit_price' => 'nullable|integer|min:0',
|
||||
'items.*.remarks' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'nc_type.required' => __('error.nonconforming.nc_type_required'),
|
||||
'nc_type.in' => __('error.nonconforming.nc_type_invalid'),
|
||||
'occurred_at.required' => __('error.nonconforming.occurred_at_required'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Material;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateNonconformingReportRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nc_type' => 'sometimes|string|in:material,process,construction,other',
|
||||
'occurred_at' => 'sometimes|date',
|
||||
'confirmed_at' => 'nullable|date',
|
||||
'site_name' => 'nullable|string|max:100',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'order_id' => 'nullable|integer|exists:orders,id',
|
||||
'item_id' => 'nullable|integer|exists:items,id',
|
||||
'defect_quantity' => 'nullable|numeric|min:0',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'defect_description' => 'nullable|string',
|
||||
'cause_analysis' => 'nullable|string',
|
||||
'corrective_action' => 'nullable|string',
|
||||
'action_completed_at' => 'nullable|date',
|
||||
'action_manager_id' => 'nullable|integer',
|
||||
'related_employee_id' => 'nullable|integer',
|
||||
'material_cost' => 'nullable|integer|min:0',
|
||||
'shipping_cost' => 'nullable|integer|min:0',
|
||||
'construction_cost' => 'nullable|integer|min:0',
|
||||
'other_cost' => 'nullable|integer|min:0',
|
||||
'remarks' => 'nullable|string',
|
||||
'drawing_location' => 'nullable|string|max:255',
|
||||
'status' => 'sometimes|string|in:RECEIVED,ANALYZING,RESOLVED,CLOSED',
|
||||
|
||||
// 자재 상세 내역
|
||||
'items' => 'nullable|array',
|
||||
'items.*.id' => 'nullable|integer',
|
||||
'items.*.item_id' => 'nullable|integer',
|
||||
'items.*.item_name' => 'required_with:items|string|max:100',
|
||||
'items.*.specification' => 'nullable|string|max:100',
|
||||
'items.*.quantity' => 'nullable|numeric|min:0',
|
||||
'items.*.unit_price' => 'nullable|integer|min:0',
|
||||
'items.*.remarks' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
}
|
||||
172
app/Models/Materials/NonconformingReport.php
Normal file
172
app/Models/Materials/NonconformingReport.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Materials;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Departments\Department;
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\Users\User;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class NonconformingReport extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'nc_number',
|
||||
'status',
|
||||
'nc_type',
|
||||
'occurred_at',
|
||||
'confirmed_at',
|
||||
'site_name',
|
||||
'department_id',
|
||||
'order_id',
|
||||
'item_id',
|
||||
'defect_quantity',
|
||||
'unit',
|
||||
'defect_description',
|
||||
'cause_analysis',
|
||||
'corrective_action',
|
||||
'action_completed_at',
|
||||
'action_manager_id',
|
||||
'related_employee_id',
|
||||
'material_cost',
|
||||
'shipping_cost',
|
||||
'construction_cost',
|
||||
'other_cost',
|
||||
'total_cost',
|
||||
'remarks',
|
||||
'drawing_location',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
'occurred_at' => 'date',
|
||||
'confirmed_at' => 'date',
|
||||
'action_completed_at' => 'date',
|
||||
'material_cost' => 'integer',
|
||||
'shipping_cost' => 'integer',
|
||||
'construction_cost' => 'integer',
|
||||
'other_cost' => 'integer',
|
||||
'total_cost' => 'integer',
|
||||
'defect_quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
// 상태 상수
|
||||
public const STATUS_RECEIVED = 'RECEIVED';
|
||||
|
||||
public const STATUS_ANALYZING = 'ANALYZING';
|
||||
|
||||
public const STATUS_RESOLVED = 'RESOLVED';
|
||||
|
||||
public const STATUS_CLOSED = 'CLOSED';
|
||||
|
||||
// 부적합 유형 상수
|
||||
public const TYPE_MATERIAL = 'material';
|
||||
|
||||
public const TYPE_PROCESS = 'process';
|
||||
|
||||
public const TYPE_CONSTRUCTION = 'construction';
|
||||
|
||||
public const TYPE_OTHER = 'other';
|
||||
|
||||
public const NC_TYPES = [
|
||||
self::TYPE_MATERIAL => '자재불량',
|
||||
self::TYPE_PROCESS => '공정불량',
|
||||
self::TYPE_CONSTRUCTION => '시공불량',
|
||||
self::TYPE_OTHER => '기타',
|
||||
];
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_RECEIVED => '접수',
|
||||
self::STATUS_ANALYZING => '분석중',
|
||||
self::STATUS_RESOLVED => '조치완료',
|
||||
self::STATUS_CLOSED => '종결',
|
||||
];
|
||||
|
||||
// ── 관계 ──
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(NonconformingReportItem::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function actionManager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'action_manager_id');
|
||||
}
|
||||
|
||||
public function relatedEmployee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'related_employee_id');
|
||||
}
|
||||
|
||||
public function files(): MorphMany
|
||||
{
|
||||
return $this->morphMany(File::class, 'fileable');
|
||||
}
|
||||
|
||||
// ── 스코프 ──
|
||||
|
||||
public function scopeStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeNcType($query, string $type)
|
||||
{
|
||||
return $query->where('nc_type', $type);
|
||||
}
|
||||
|
||||
// ── 헬퍼 ──
|
||||
|
||||
public function recalculateTotalCost(): void
|
||||
{
|
||||
$this->total_cost = $this->material_cost + $this->shipping_cost
|
||||
+ $this->construction_cost + $this->other_cost;
|
||||
}
|
||||
|
||||
public function recalculateMaterialCost(): void
|
||||
{
|
||||
$this->material_cost = (int) $this->items()->sum('amount');
|
||||
$this->recalculateTotalCost();
|
||||
}
|
||||
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CLOSED;
|
||||
}
|
||||
}
|
||||
45
app/Models/Materials/NonconformingReportItem.php
Normal file
45
app/Models/Materials/NonconformingReportItem.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Materials;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NonconformingReportItem extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'nonconforming_report_id',
|
||||
'item_id',
|
||||
'item_name',
|
||||
'specification',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'amount',
|
||||
'sort_order',
|
||||
'remarks',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
'quantity' => 'decimal:2',
|
||||
'unit_price' => 'integer',
|
||||
'amount' => 'integer',
|
||||
];
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NonconformingReport::class, 'nonconforming_report_id');
|
||||
}
|
||||
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('nonconforming_reports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->string('nc_number', 30)->comment('부적합번호 NC-YYYYMMDD-NNN');
|
||||
$table->string('status', 20)->default('RECEIVED')->comment('상태: RECEIVED/ANALYZING/RESOLVED/CLOSED');
|
||||
$table->string('nc_type', 20)->comment('부적합유형: material/process/construction/other');
|
||||
$table->date('occurred_at')->comment('발생일');
|
||||
$table->date('confirmed_at')->nullable()->comment('불량확인일');
|
||||
$table->string('site_name', 100)->nullable()->comment('현장명');
|
||||
$table->unsignedBigInteger('department_id')->nullable()->comment('부서');
|
||||
$table->unsignedBigInteger('order_id')->nullable()->comment('관련 수주');
|
||||
$table->unsignedBigInteger('item_id')->nullable()->comment('관련 품목');
|
||||
$table->decimal('defect_quantity', 10, 2)->nullable()->comment('불량 수량');
|
||||
$table->string('unit', 20)->nullable()->comment('단위');
|
||||
$table->text('defect_description')->nullable()->comment('불량 상세 설명');
|
||||
$table->text('cause_analysis')->nullable()->comment('원인 분석');
|
||||
$table->text('corrective_action')->nullable()->comment('처리 방안 및 개선 사항');
|
||||
$table->date('action_completed_at')->nullable()->comment('조치 완료일');
|
||||
$table->unsignedBigInteger('action_manager_id')->nullable()->comment('조치 담당자');
|
||||
$table->unsignedBigInteger('related_employee_id')->nullable()->comment('관련 직원');
|
||||
$table->decimal('material_cost', 12, 0)->default(0)->comment('자재 비용');
|
||||
$table->decimal('shipping_cost', 12, 0)->default(0)->comment('운송 비용');
|
||||
$table->decimal('construction_cost', 12, 0)->default(0)->comment('시공 비용');
|
||||
$table->decimal('other_cost', 12, 0)->default(0)->comment('기타 비용');
|
||||
$table->decimal('total_cost', 12, 0)->default(0)->comment('비용 합계');
|
||||
$table->text('remarks')->nullable()->comment('비고');
|
||||
$table->string('drawing_location', 255)->nullable()->comment('도면 저장 위치');
|
||||
$table->json('options')->nullable()->comment('확장 속성');
|
||||
$table->unsignedBigInteger('created_by')->default(0)->comment('등록자');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
|
||||
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'nc_type']);
|
||||
$table->index(['tenant_id', 'occurred_at']);
|
||||
$table->unique(['tenant_id', 'nc_number']);
|
||||
});
|
||||
|
||||
Schema::create('nonconforming_report_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->unsignedBigInteger('nonconforming_report_id')->comment('부적합 보고서 ID');
|
||||
$table->unsignedBigInteger('item_id')->nullable()->comment('품목 마스터 연결');
|
||||
$table->string('item_name', 100)->comment('품목명');
|
||||
$table->string('specification', 100)->nullable()->comment('규격/사양');
|
||||
$table->decimal('quantity', 10, 2)->default(0)->comment('수량');
|
||||
$table->decimal('unit_price', 12, 0)->default(0)->comment('단가');
|
||||
$table->decimal('amount', 12, 0)->default(0)->comment('금액');
|
||||
$table->integer('sort_order')->default(0)->comment('정렬 순서');
|
||||
$table->string('remarks', 255)->nullable()->comment('비고');
|
||||
$table->json('options')->nullable()->comment('확장 속성');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('nonconforming_report_id');
|
||||
$table->foreign('nonconforming_report_id')
|
||||
->references('id')
|
||||
->on('nonconforming_reports')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('nonconforming_report_items');
|
||||
Schema::dropIfExists('nonconforming_reports');
|
||||
}
|
||||
};
|
||||
@@ -529,6 +529,17 @@
|
||||
],
|
||||
'tenant_access_denied' => '해당 테넌트에 대한 접근 권한이 없습니다.',
|
||||
|
||||
// 부적합관리 관련
|
||||
'nonconforming' => [
|
||||
'nc_type_required' => '부적합 유형은 필수입니다.',
|
||||
'nc_type_invalid' => '유효하지 않은 부적합 유형입니다.',
|
||||
'occurred_at_required' => '발생일은 필수입니다.',
|
||||
'closed_cannot_edit' => '종결된 부적합 보고서는 수정할 수 없습니다.',
|
||||
'closed_cannot_delete' => '종결된 부적합 보고서는 삭제할 수 없습니다.',
|
||||
'invalid_status_transition' => "상태를 ':from'에서 ':to'(으)로 변경할 수 없습니다.",
|
||||
'analysis_required' => '조치완료로 변경하려면 원인 분석과 처리 방안을 먼저 입력해야 합니다.',
|
||||
],
|
||||
|
||||
// 데모 테넌트 관련
|
||||
'demo_tenant' => [
|
||||
'not_found' => '데모 테넌트를 찾을 수 없습니다.',
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
use App\Http\Controllers\Api\V1\ItemsController;
|
||||
use App\Http\Controllers\Api\V1\ItemsFileController;
|
||||
use App\Http\Controllers\Api\V1\LaborController;
|
||||
use App\Http\Controllers\Api\V1\NonconformingReportController;
|
||||
use App\Http\Controllers\Api\V1\PurchaseController;
|
||||
use App\Http\Controllers\Api\V1\ReceivingController;
|
||||
use App\Http\Controllers\Api\V1\ShipmentController;
|
||||
@@ -128,6 +129,17 @@
|
||||
Route::delete('/{id}', [ShipmentController::class, 'destroy'])->whereNumber('id')->name('v1.shipments.destroy');
|
||||
});
|
||||
|
||||
// Nonconforming Report API (부적합관리)
|
||||
Route::prefix('material/nonconforming-reports')->group(function () {
|
||||
Route::get('', [NonconformingReportController::class, 'index'])->name('v1.nonconforming-reports.index');
|
||||
Route::get('/stats', [NonconformingReportController::class, 'stats'])->name('v1.nonconforming-reports.stats');
|
||||
Route::post('', [NonconformingReportController::class, 'store'])->name('v1.nonconforming-reports.store');
|
||||
Route::get('/{id}', [NonconformingReportController::class, 'show'])->whereNumber('id')->name('v1.nonconforming-reports.show');
|
||||
Route::put('/{id}', [NonconformingReportController::class, 'update'])->whereNumber('id')->name('v1.nonconforming-reports.update');
|
||||
Route::delete('/{id}', [NonconformingReportController::class, 'destroy'])->whereNumber('id')->name('v1.nonconforming-reports.destroy');
|
||||
Route::patch('/{id}/status', [NonconformingReportController::class, 'changeStatus'])->whereNumber('id')->name('v1.nonconforming-reports.change-status');
|
||||
});
|
||||
|
||||
// Vehicle Dispatch API (배차차량 관리)
|
||||
Route::prefix('vehicle-dispatches')->group(function () {
|
||||
Route::get('', [VehicleDispatchController::class, 'index'])->name('v1.vehicle-dispatches.index');
|
||||
|
||||
Reference in New Issue
Block a user