feat: [품질관리] 품질관리서/실적신고/검사 API

- QualityDocument CRUD + 수주 연결 + 개소별 데이터 저장
- PerformanceReport 실적신고 확인/메모 API
- Inspection 검사 설정 + product_code 전파 수정
- 수주선택 API에 client_name 필드 추가
- 절곡 검사 프로파일 분리 (S1/S2/S3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 02:57:48 +09:00
parent 091719e81b
commit f9cd219f67
29 changed files with 2495 additions and 10 deletions

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Models\Quote\Quote;
use Illuminate\Console\Command;
class BackfillQuoteProductCodeCommand extends Command
{
protected $signature = 'data:backfill-quote-product-code {--dry-run : 실제 저장하지 않고 결과만 출력}';
protected $description = 'quotes.product_code가 비어있는 레코드에 calculation_inputs.items[0].productCode 값 보정';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$quotes = Quote::whereNull('product_code')
->whereNotNull('calculation_inputs')
->get();
$this->info("대상: {$quotes->count()}".($dryRun ? ' (dry-run)' : ''));
$updated = 0;
$skipped = 0;
foreach ($quotes as $quote) {
$inputs = $quote->calculation_inputs;
if (! is_array($inputs)) {
$inputs = json_decode($inputs, true);
}
$productCode = $inputs['items'][0]['productCode'] ?? null;
if (! $productCode) {
$skipped++;
$this->line(" SKIP #{$quote->id} ({$quote->quote_number}) — productCode 없음");
continue;
}
if (! $dryRun) {
$quote->update(['product_code' => $productCode]);
}
$updated++;
$this->line(' '.($dryRun ? 'WOULD ' : '')."UPDATE #{$quote->id} ({$quote->quote_number}) → {$productCode}");
}
$this->info("완료: 보정 {$updated}건, 스킵 {$skipped}");
return self::SUCCESS;
}
}

View File

@@ -14,7 +14,7 @@
* - 두께 매핑 (normalizeThickness)
* - 면적 계산 (calculateArea)
*
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5
* @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5
*/
class Legacy5130Calculator
{

View File

@@ -34,6 +34,16 @@ public function stats(Request $request)
}, __('message.inspection.fetched'));
}
/**
* 캘린더 스케줄 조회
*/
public function calendar(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->calendar($request->all());
}, __('message.inspection.fetched'));
}
/**
* 단건 조회
*/

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\PerformanceReportConfirmRequest;
use App\Http\Requests\Quality\PerformanceReportMemoRequest;
use App\Services\PerformanceReportService;
use Illuminate\Http\Request;
class PerformanceReportController extends Controller
{
public function __construct(private PerformanceReportService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function confirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->confirm($request->validated()['ids']);
}, __('message.updated'));
}
public function unconfirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->unconfirm($request->validated()['ids']);
}, __('message.updated'));
}
public function updateMemo(PerformanceReportMemoRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$data = $request->validated();
return $this->service->updateMemo($data['ids'], $data['memo']);
}, __('message.updated'));
}
public function missing(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->missing($request->all());
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\QualityDocumentStoreRequest;
use App\Http\Requests\Quality\QualityDocumentUpdateRequest;
use App\Services\QualityDocumentService;
use Illuminate\Http\Request;
class QualityDocumentController extends Controller
{
public function __construct(private QualityDocumentService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function calendar(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->calendar($request->all());
}, __('message.fetched'));
}
public function availableOrders(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->availableOrders($request->all());
}, __('message.fetched'));
}
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
public function store(QualityDocumentStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
public function update(QualityDocumentUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
public function complete(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->complete($id);
}, __('message.updated'));
}
public function attachOrders(Request $request, int $id)
{
$request->validate([
'order_ids' => ['required', 'array', 'min:1'],
'order_ids.*' => ['required', 'integer'],
]);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->attachOrders($id, $request->input('order_ids'));
}, __('message.updated'));
}
public function detachOrder(int $id, int $orderId)
{
return ApiResponse::handle(function () use ($id, $orderId) {
return $this->service->detachOrder($id, $orderId);
}, __('message.updated'));
}
public function inspectLocation(Request $request, int $id, int $locId)
{
$request->validate([
'post_width' => ['nullable', 'integer'],
'post_height' => ['nullable', 'integer'],
'change_reason' => ['nullable', 'string', 'max:500'],
'inspection_status' => ['nullable', 'string', 'in:pending,completed'],
]);
return ApiResponse::handle(function () use ($request, $id, $locId) {
return $this->service->inspectLocation($id, $locId, $request->all());
}, __('message.updated'));
}
public function requestDocument(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->requestDocument($id);
}, __('message.fetched'));
}
public function resultDocument(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->resultDocument($id);
}, __('message.fetched'));
}
}

View File

@@ -22,6 +22,7 @@ public function rules(): array
Inspection::TYPE_FQC,
])],
'lot_no' => ['required', 'string', 'max:50'],
'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'],
'item_name' => ['nullable', 'string', 'max:200'],
'process_name' => ['nullable', 'string', 'max:100'],
'quantity' => ['nullable', 'numeric', 'min:0'],

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class PerformanceReportConfirmRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
];
}
public function messages(): array
{
return [
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
'ids.min' => __('validation.min.array', ['attribute' => '실적신고 ID', 'min' => 1]),
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class PerformanceReportMemoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
'memo' => ['required', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
'memo.required' => __('validation.required', ['attribute' => '메모']),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class QualityDocumentStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'site_name' => ['required', 'string', 'max:200'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
'received_date' => ['nullable', 'date'],
'options' => ['nullable', 'array'],
'options.manager' => ['nullable', 'array'],
'options.manager.name' => ['nullable', 'string', 'max:50'],
'options.manager.phone' => ['nullable', 'string', 'max:30'],
'options.inspection' => ['nullable', 'array'],
'options.inspection.request_date' => ['nullable', 'date'],
'options.inspection.start_date' => ['nullable', 'date'],
'options.inspection.end_date' => ['nullable', 'date'],
'options.site_address' => ['nullable', 'array'],
'options.construction_site' => ['nullable', 'array'],
'options.material_distributor' => ['nullable', 'array'],
'options.contractor' => ['nullable', 'array'],
'options.supervisor' => ['nullable', 'array'],
'order_ids' => ['nullable', 'array'],
'order_ids.*' => ['integer', 'exists:orders,id'],
];
}
public function messages(): array
{
return [
'site_name.required' => __('validation.required', ['attribute' => '현장명']),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class QualityDocumentUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'site_name' => ['sometimes', 'string', 'max:200'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
'received_date' => ['nullable', 'date'],
'options' => ['nullable', 'array'],
'options.manager' => ['nullable', 'array'],
'options.manager.name' => ['nullable', 'string', 'max:50'],
'options.manager.phone' => ['nullable', 'string', 'max:30'],
'options.inspection' => ['nullable', 'array'],
'options.inspection.request_date' => ['nullable', 'date'],
'options.inspection.start_date' => ['nullable', 'date'],
'options.inspection.end_date' => ['nullable', 'date'],
'options.site_address' => ['nullable', 'array'],
'options.construction_site' => ['nullable', 'array'],
'options.material_distributor' => ['nullable', 'array'],
'options.contractor' => ['nullable', 'array'],
'options.supervisor' => ['nullable', 'array'],
'order_ids' => ['nullable', 'array'],
'order_ids.*' => ['integer', 'exists:orders,id'],
'locations' => ['nullable', 'array'],
'locations.*.id' => ['required', 'integer'],
'locations.*.post_width' => ['nullable', 'integer'],
'locations.*.post_height' => ['nullable', 'integer'],
'locations.*.change_reason' => ['nullable', 'string', 'max:500'],
'locations.*.inspection_data' => ['nullable', 'array'],
];
}
}

View File

@@ -39,6 +39,16 @@ public function rules(): array
'inspection_data.nonConformingContent' => 'nullable|string|max:1000',
'inspection_data.templateValues' => 'nullable|array',
'inspection_data.templateValues.*' => 'nullable',
// 절곡 제품별 검사 데이터
'inspection_data.products' => 'nullable|array',
'inspection_data.products.*.id' => 'required_with:inspection_data.products|string',
'inspection_data.products.*.bendingStatus' => ['nullable', Rule::in(['양호', '불량'])],
'inspection_data.products.*.lengthMeasured' => 'nullable|string|max:50',
'inspection_data.products.*.widthMeasured' => 'nullable|string|max:50',
'inspection_data.products.*.gapPoints' => 'nullable|array',
'inspection_data.products.*.gapPoints.*.point' => 'nullable|string',
'inspection_data.products.*.gapPoints.*.designValue' => 'nullable|string',
'inspection_data.products.*.gapPoints.*.measured' => 'nullable|string|max:50',
];
}

View File

@@ -4,6 +4,7 @@
use App\Models\Items\Item;
use App\Models\Members\User;
use App\Models\Production\WorkOrder;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -23,6 +24,7 @@
* @property string|null $inspection_date 검사일
* @property int|null $item_id 품목 ID
* @property string $lot_no LOT번호
* @property int|null $work_order_id 작업지시 ID (PQC/FQC용)
* @property int|null $inspector_id 검사자 ID
* @property array|null $meta 메타정보 (process_name, quantity, unit 등)
* @property array|null $items 검사항목 배열
@@ -47,6 +49,7 @@ class Inspection extends Model
'inspection_date',
'item_id',
'lot_no',
'work_order_id',
'inspector_id',
'meta',
'items',
@@ -92,6 +95,14 @@ class Inspection extends Model
// ===== Relationships =====
/**
* 작업지시 (PQC/FQC용)
*/
public function workOrder()
{
return $this->belongsTo(WorkOrder::class);
}
/**
* 품목
*/

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class PerformanceReport extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'performance_reports';
const STATUS_UNCONFIRMED = 'unconfirmed';
const STATUS_CONFIRMED = 'confirmed';
const STATUS_REPORTED = 'reported';
protected $fillable = [
'tenant_id',
'quality_document_id',
'year',
'quarter',
'confirmation_status',
'confirmed_date',
'confirmed_by',
'memo',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'confirmed_date' => 'date',
'year' => 'integer',
'quarter' => 'integer',
];
// ===== Relationships =====
public function qualityDocument()
{
return $this->belongsTo(QualityDocument::class);
}
public function confirmer()
{
return $this->belongsTo(User::class, 'confirmed_by');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
// ===== Status Helpers =====
public function isUnconfirmed(): bool
{
return $this->confirmation_status === self::STATUS_UNCONFIRMED;
}
public function isConfirmed(): bool
{
return $this->confirmation_status === self::STATUS_CONFIRMED;
}
public function isReported(): bool
{
return $this->confirmation_status === self::STATUS_REPORTED;
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Members\User;
use App\Models\Orders\Client;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class QualityDocument extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'quality_documents';
const STATUS_RECEIVED = 'received';
const STATUS_IN_PROGRESS = 'in_progress';
const STATUS_COMPLETED = 'completed';
protected $fillable = [
'tenant_id',
'quality_doc_number',
'site_name',
'status',
'client_id',
'inspector_id',
'received_date',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'options' => 'array',
'received_date' => 'date',
];
// ===== Relationships =====
public function client()
{
return $this->belongsTo(Client::class, 'client_id');
}
public function inspector()
{
return $this->belongsTo(User::class, 'inspector_id');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function documentOrders()
{
return $this->hasMany(QualityDocumentOrder::class);
}
public function locations()
{
return $this->hasMany(QualityDocumentLocation::class);
}
public function performanceReport()
{
return $this->hasOne(PerformanceReport::class);
}
// ===== 채번 =====
public static function generateDocNumber(int $tenantId): string
{
$prefix = 'KD-QD';
$yearMonth = now()->format('Ym');
$lastNo = static::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('quality_doc_number', 'like', "{$prefix}-{$yearMonth}-%")
->orderByDesc('quality_doc_number')
->value('quality_doc_number');
if ($lastNo) {
$seq = (int) substr($lastNo, -4) + 1;
} else {
$seq = 1;
}
return sprintf('%s-%s-%04d', $prefix, $yearMonth, $seq);
}
// ===== Status Helpers =====
public function isReceived(): bool
{
return $this->status === self::STATUS_RECEIVED;
}
public function isInProgress(): bool
{
return $this->status === self::STATUS_IN_PROGRESS;
}
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
public static function mapStatusToFrontend(string $status): string
{
return match ($status) {
self::STATUS_RECEIVED => 'reception',
self::STATUS_IN_PROGRESS => 'in_progress',
self::STATUS_COMPLETED => 'completed',
default => $status,
};
}
public static function mapStatusFromFrontend(string $status): string
{
return match ($status) {
'reception' => self::STATUS_RECEIVED,
default => $status,
};
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Documents\Document;
use App\Models\Orders\OrderItem;
use Illuminate\Database\Eloquent\Model;
class QualityDocumentLocation extends Model
{
protected $table = 'quality_document_locations';
const STATUS_PENDING = 'pending';
const STATUS_COMPLETED = 'completed';
protected $fillable = [
'quality_document_id',
'quality_document_order_id',
'order_item_id',
'post_width',
'post_height',
'change_reason',
'inspection_data',
'document_id',
'inspection_status',
];
protected $casts = [
'inspection_data' => 'array',
];
public function qualityDocument()
{
return $this->belongsTo(QualityDocument::class);
}
public function qualityDocumentOrder()
{
return $this->belongsTo(QualityDocumentOrder::class);
}
public function orderItem()
{
return $this->belongsTo(OrderItem::class);
}
public function document()
{
return $this->belongsTo(Document::class);
}
public function isPending(): bool
{
return $this->inspection_status === self::STATUS_PENDING;
}
public function isCompleted(): bool
{
return $this->inspection_status === self::STATUS_COMPLETED;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Orders\Order;
use Illuminate\Database\Eloquent\Model;
class QualityDocumentOrder extends Model
{
protected $table = 'quality_document_orders';
protected $fillable = [
'quality_document_id',
'order_id',
];
public function qualityDocument()
{
return $this->belongsTo(QualityDocument::class);
}
public function order()
{
return $this->belongsTo(Order::class);
}
public function locations()
{
return $this->hasMany(QualityDocumentLocation::class);
}
}

View File

@@ -33,7 +33,7 @@ public function index(array $params)
$query = Inspection::query()
->where('tenant_id', $tenantId)
->with(['inspector:id,name', 'item:id,item_name']);
->with(['inspector:id,name', 'item:id,item_name', 'workOrder:id,work_order_no']);
// 검색어 (검사번호, LOT번호)
if ($q !== '') {
@@ -126,7 +126,7 @@ public function show(int $id)
$tenantId = $this->tenantId();
$inspection = Inspection::where('tenant_id', $tenantId)
->with(['inspector:id,name', 'item:id,item_name'])
->with(['inspector:id,name', 'item:id,item_name', 'workOrder:id,work_order_no'])
->find($id);
if (! $inspection) {
@@ -183,6 +183,7 @@ public function store(array $data)
'inspection_type' => $data['inspection_type'],
'request_date' => $data['request_date'] ?? now()->toDateString(),
'lot_no' => $data['lot_no'],
'work_order_id' => $data['work_order_id'] ?? null,
'inspector_id' => $data['inspector_id'] ?? null,
'meta' => $meta,
'items' => $items,
@@ -200,7 +201,7 @@ public function store(array $data)
$inspection->toArray()
);
return $this->transformToFrontend($inspection->load(['inspector:id,name']));
return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no']));
});
}
@@ -277,7 +278,7 @@ public function update(int $id, array $data)
$inspection->fresh()->toArray()
);
return $this->transformToFrontend($inspection->load(['inspector:id,name']));
return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no']));
});
}
@@ -360,10 +361,83 @@ public function complete(int $id, array $data)
$inspection->fresh()->toArray()
);
return $this->transformToFrontend($inspection->load(['inspector:id,name']));
return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no']));
});
}
/**
* 캘린더 스케줄 조회
*/
public function calendar(array $params): array
{
$tenantId = $this->tenantId();
$year = (int) ($params['year'] ?? now()->year);
$month = (int) ($params['month'] ?? now()->month);
// 해당 월의 시작일/종료일
$startDate = sprintf('%04d-%02d-01', $year, $month);
$endDate = date('Y-m-t', strtotime($startDate));
$query = Inspection::query()
->where('tenant_id', $tenantId)
->where(function ($q) use ($startDate, $endDate) {
$q->whereBetween('request_date', [$startDate, $endDate])
->orWhereBetween('inspection_date', [$startDate, $endDate]);
})
->with(['inspector:id,name', 'item:id,item_name']);
// 검사자 필터
if (! empty($params['inspector'])) {
$query->whereHas('inspector', function ($q) use ($params) {
$q->where('name', $params['inspector']);
});
}
// 상태 필터
if (! empty($params['status'])) {
$status = $params['status'] === 'reception' ? self::mapStatusFromFrontend('reception') : $params['status'];
$query->where('status', $status);
}
return $query->orderBy('request_date')
->get()
->map(fn (Inspection $item) => [
'id' => $item->id,
'start_date' => $item->request_date?->format('Y-m-d'),
'end_date' => $item->inspection_date?->format('Y-m-d') ?? $item->request_date?->format('Y-m-d'),
'inspector' => $item->inspector?->name ?? '',
'site_name' => $item->item?->item_name ?? ($item->meta['process_name'] ?? $item->inspection_no),
'status' => self::mapStatusToFrontend($item->status),
])
->values()
->toArray();
}
/**
* 상태를 프론트엔드 형식으로 매핑
*/
private static function mapStatusToFrontend(string $status): string
{
return match ($status) {
Inspection::STATUS_WAITING => 'reception',
Inspection::STATUS_IN_PROGRESS => 'in_progress',
Inspection::STATUS_COMPLETED => 'completed',
default => $status,
};
}
/**
* 프론트엔드 상태를 DB 상태로 매핑
*/
private static function mapStatusFromFrontend(string $status): string
{
return match ($status) {
'reception' => Inspection::STATUS_WAITING,
default => $status,
};
}
/**
* DB 데이터를 프론트엔드 형식으로 변환
*/
@@ -380,6 +454,8 @@ private function transformToFrontend(Inspection $inspection): array
'inspection_date' => $inspection->inspection_date?->format('Y-m-d'),
'item_name' => $inspection->item?->item_name ?? ($meta['item_name'] ?? null),
'lot_no' => $inspection->lot_no,
'work_order_id' => $inspection->work_order_id,
'work_order_no' => $inspection->workOrder?->work_order_no,
'process_name' => $meta['process_name'] ?? null,
'quantity' => $meta['quantity'] ?? null,
'unit' => $meta['unit'] ?? null,

View File

@@ -1325,9 +1325,13 @@ public function createProductionOrder(int $orderId, array $data)
// 작업지시번호 생성
$workOrderNo = $this->generateWorkOrderNo($tenantId);
// 절곡 공정이면 bending_info 자동 생성
// 공정 옵션 초기화 (보조 공정 플래그 포함)
$workOrderOptions = null;
if ($processId) {
$process = \App\Models\Process::find($processId);
if ($process && ! empty($process->options['is_auxiliary'])) {
$workOrderOptions = ['is_auxiliary' => true];
}
// 이 작업지시에 포함되는 노드 ID만 추출
$nodeIds = collect($items)
->pluck('order_node_id')
@@ -1338,7 +1342,7 @@ public function createProductionOrder(int $orderId, array $data)
$buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
if ($buildResult) {
$workOrderOptions = ['bending_info' => $buildResult['bending_info']];
$workOrderOptions = array_merge($workOrderOptions ?? [], ['bending_info' => $buildResult['bending_info']]);
}
}
@@ -1410,6 +1414,8 @@ public function createProductionOrder(int $orderId, array $data)
$woItemOptions = array_filter([
'floor' => $orderItem->floor_code,
'code' => $orderItem->symbol_code,
'product_code' => ! empty($nodeOptions['product_code']) ? $nodeOptions['product_code'] : null,
'product_name' => ! empty($nodeOptions['product_name']) ? $nodeOptions['product_name'] : null,
'width' => $woWidth,
'height' => $woHeight,
'cutting_info' => $nodeOptions['cutting_info'] ?? null,

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Services;
use App\Models\Qualitys\PerformanceReport;
use App\Services\Audit\AuditLogger;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class PerformanceReportService extends Service
{
private const AUDIT_TARGET = 'performance_report';
public function __construct(
private readonly AuditLogger $auditLogger,
private readonly QualityDocumentService $qualityDocumentService
) {}
/**
* 목록 조회
*/
public function index(array $params)
{
$tenantId = $this->tenantId();
$perPage = (int) ($params['per_page'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$year = $params['year'] ?? null;
$quarter = $params['quarter'] ?? null;
$confirmStatus = $params['confirm_status'] ?? null;
$query = PerformanceReport::query()
->where('performance_reports.tenant_id', $tenantId)
->with(['qualityDocument.client', 'qualityDocument.locations', 'confirmer:id,name']);
if ($q !== '') {
$query->whereHas('qualityDocument', function ($qq) use ($q) {
$qq->where('quality_doc_number', 'like', "%{$q}%")
->orWhere('site_name', 'like', "%{$q}%");
});
}
if ($year !== null) {
$query->where('year', $year);
}
if ($quarter !== null) {
$query->where('quarter', $quarter);
}
if ($confirmStatus !== null) {
$query->where('confirmation_status', $confirmStatus);
}
$query->orderByDesc('performance_reports.id');
$paginated = $query->paginate($perPage);
$transformedData = $paginated->getCollection()->map(fn ($report) => $this->transformToFrontend($report));
return [
'items' => $transformedData,
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
];
}
/**
* 통계 조회
*/
public function stats(array $params = []): array
{
$tenantId = $this->tenantId();
$query = PerformanceReport::where('performance_reports.tenant_id', $tenantId);
if (! empty($params['year'])) {
$query->where('performance_reports.year', $params['year']);
}
if (! empty($params['quarter'])) {
$query->where('performance_reports.quarter', $params['quarter']);
}
$counts = (clone $query)
->select('confirmation_status', DB::raw('count(*) as count'))
->groupBy('confirmation_status')
->pluck('count', 'confirmation_status')
->toArray();
$totalLocations = (clone $query)
->join('quality_documents', 'quality_documents.id', '=', 'performance_reports.quality_document_id')
->join('quality_document_locations', 'quality_document_locations.quality_document_id', '=', 'quality_documents.id')
->count('quality_document_locations.id');
return [
'total_count' => array_sum($counts),
'confirmed_count' => $counts[PerformanceReport::STATUS_CONFIRMED] ?? 0,
'unconfirmed_count' => $counts[PerformanceReport::STATUS_UNCONFIRMED] ?? 0,
'total_locations' => $totalLocations,
];
}
/**
* 일괄 확정
*/
public function confirm(array $ids)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($ids, $tenantId, $userId) {
$reports = PerformanceReport::where('tenant_id', $tenantId)
->whereIn('id', $ids)
->with(['qualityDocument'])
->get();
$errors = [];
foreach ($reports as $report) {
if ($report->isConfirmed() || $report->isReported()) {
continue;
}
// 필수정보 검증
$requiredInfo = $this->qualityDocumentService->calculateRequiredInfo($report->qualityDocument);
if ($requiredInfo !== '완료') {
$errors[] = [
'id' => $report->id,
'quality_doc_number' => $report->qualityDocument->quality_doc_number,
'reason' => $requiredInfo,
];
continue;
}
$report->update([
'confirmation_status' => PerformanceReport::STATUS_CONFIRMED,
'confirmed_date' => now()->toDateString(),
'confirmed_by' => $userId,
'updated_by' => $userId,
]);
}
if (! empty($errors)) {
throw new BadRequestHttpException(json_encode([
'message' => __('error.quality.confirm_failed'),
'errors' => $errors,
]));
}
return ['confirmed_count' => count($ids) - count($errors)];
});
}
/**
* 일괄 확정 해제
*/
public function unconfirm(array $ids)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($ids, $tenantId, $userId) {
PerformanceReport::where('tenant_id', $tenantId)
->whereIn('id', $ids)
->where('confirmation_status', PerformanceReport::STATUS_CONFIRMED)
->update([
'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED,
'confirmed_date' => null,
'confirmed_by' => null,
'updated_by' => $userId,
]);
return ['unconfirmed_count' => count($ids)];
});
}
/**
* 일괄 메모 업데이트
*/
public function updateMemo(array $ids, string $memo)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
PerformanceReport::where('tenant_id', $tenantId)
->whereIn('id', $ids)
->update([
'memo' => $memo,
'updated_by' => $userId,
]);
return ['updated_count' => count($ids)];
}
/**
* 누락체크 (출고완료 but 제품검사 미등록)
*/
public function missing(array $params): array
{
$tenantId = $this->tenantId();
// 품질관리서가 등록된 수주 ID
$registeredOrderIds = DB::table('quality_document_orders')
->join('quality_documents', 'quality_documents.id', '=', 'quality_document_orders.quality_document_id')
->where('quality_documents.tenant_id', $tenantId)
->pluck('quality_document_orders.order_id');
// 출고완료 상태이지만 품질관리서 미등록 수주
$query = DB::table('orders')
->where('tenant_id', $tenantId)
->whereNotIn('id', $registeredOrderIds)
->where('status_code', 'SHIPPED'); // TODO: 출고완료 상태 추가 시 상수 확인
if (! empty($params['year'])) {
$query->whereYear('created_at', $params['year']);
}
if (! empty($params['quarter'])) {
$quarter = (int) $params['quarter'];
$startMonth = ($quarter - 1) * 3 + 1;
$endMonth = $quarter * 3;
$query->whereMonth('created_at', '>=', $startMonth)
->whereMonth('created_at', '<=', $endMonth);
}
return $query->orderByDesc('id')
->limit(100)
->get()
->map(fn ($order) => [
'id' => $order->id,
'order_number' => $order->order_no ?? '',
'site_name' => $order->site_name ?? '',
'client' => '', // 별도 조인 필요
'delivery_date' => $order->delivery_date ?? '',
])
->toArray();
}
/**
* DB → 프론트엔드 변환
*/
private function transformToFrontend(PerformanceReport $report): array
{
$doc = $report->qualityDocument;
return [
'id' => $report->id,
'quality_doc_number' => $doc?->quality_doc_number ?? '',
'created_date' => $report->created_at?->format('Y-m-d') ?? '',
'site_name' => $doc?->site_name ?? '',
'client' => $doc?->client?->name ?? '',
'location_count' => $doc?->locations?->count() ?? 0,
'required_info' => $doc ? $this->qualityDocumentService->calculateRequiredInfo($doc) : '',
'confirm_status' => $report->confirmation_status === PerformanceReport::STATUS_CONFIRMED ? 'confirmed' : 'unconfirmed',
'confirm_date' => $report->confirmed_date?->format('Y-m-d'),
'memo' => $report->memo ?? '',
'year' => $report->year,
'quarter' => $report->quarter,
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -321,7 +321,7 @@ public function store(array $data): Quote
// 제품 정보
'product_category' => $data['product_category'] ?? Quote::CATEGORY_SCREEN,
'product_id' => $data['product_id'] ?? null,
'product_code' => $data['product_code'] ?? null,
'product_code' => $data['product_code'] ?? $this->extractProductCodeFromInputs($data),
'product_name' => $data['product_name'] ?? null,
// 규격 정보
'open_size_width' => $data['open_size_width'] ?? null,
@@ -418,7 +418,7 @@ public function update(int $id, array $data): Quote
// 제품 정보
'product_category' => $data['product_category'] ?? $quote->product_category,
'product_id' => $data['product_id'] ?? $quote->product_id,
'product_code' => $data['product_code'] ?? $quote->product_code,
'product_code' => $data['product_code'] ?? $this->extractProductCodeFromInputs($data) ?? $quote->product_code,
'product_name' => $data['product_name'] ?? $quote->product_name,
// 규격 정보
'open_size_width' => $data['open_size_width'] ?? $quote->open_size_width,
@@ -799,6 +799,22 @@ private function resolveLocationIndex(QuoteItem $quoteItem, array $productItems)
return 0;
}
/**
* calculation_inputs에서 첫 번째 개소의 productCode 추출
* 다중 개소 시 첫 번째를 대표값으로 사용
*/
private function extractProductCodeFromInputs(array $data): ?string
{
$inputs = $data['calculation_inputs'] ?? null;
if (! $inputs || ! is_array($inputs)) {
return null;
}
$items = $inputs['items'] ?? [];
return $items[0]['productCode'] ?? null;
}
/**
* 수주번호 생성
* 형식: ORD-YYMMDD-NNN (예: ORD-260105-001)

View File

@@ -0,0 +1,34 @@
<?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::table('inspections', function (Blueprint $table) {
$table->unsignedBigInteger('work_order_id')
->nullable()
->after('lot_no')
->comment('작업지시 ID (PQC/FQC용)');
$table->foreign('work_order_id')
->references('id')
->on('work_orders')
->nullOnDelete();
$table->index('work_order_id');
});
}
public function down(): void
{
Schema::table('inspections', function (Blueprint $table) {
$table->dropForeign(['work_order_id']);
$table->dropIndex(['work_order_id']);
$table->dropColumn('work_order_id');
});
}
};

View File

@@ -0,0 +1,39 @@
<?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('quality_documents', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
$table->string('quality_doc_number', 30)->comment('품질관리서 번호');
$table->string('site_name')->comment('현장명');
$table->string('status', 20)->default('received')->comment('received/in_progress/completed');
$table->foreignId('client_id')->nullable()->constrained('clients')->comment('수주처');
$table->foreignId('inspector_id')->nullable()->constrained('users')->comment('검사자');
$table->date('received_date')->nullable()->comment('접수일');
$table->json('options')->nullable()->comment('관련자정보, 검사정보, 현장주소 등');
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->unsignedBigInteger('deleted_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'quality_doc_number']);
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'client_id']);
$table->index(['tenant_id', 'inspector_id']);
$table->index(['tenant_id', 'received_date']);
});
}
public function down(): void
{
Schema::dropIfExists('quality_documents');
}
};

View File

@@ -0,0 +1,25 @@
<?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('quality_document_orders', function (Blueprint $table) {
$table->id();
$table->foreignId('quality_document_id')->constrained()->cascadeOnDelete();
$table->foreignId('order_id')->constrained('orders');
$table->timestamps();
$table->unique(['quality_document_id', 'order_id']);
});
}
public function down(): void
{
Schema::dropIfExists('quality_document_orders');
}
};

View File

@@ -0,0 +1,31 @@
<?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('quality_document_locations', function (Blueprint $table) {
$table->id();
$table->foreignId('quality_document_id')->constrained()->cascadeOnDelete();
$table->foreignId('quality_document_order_id')->constrained('quality_document_orders', 'id', 'qdl_qdo_id_fk')->cascadeOnDelete();
$table->foreignId('order_item_id')->constrained('order_items');
$table->integer('post_width')->nullable()->comment('시공후 가로');
$table->integer('post_height')->nullable()->comment('시공후 세로');
$table->string('change_reason')->nullable()->comment('규격 변경사유');
$table->foreignId('document_id')->nullable()->comment('검사성적서 문서 ID');
$table->string('inspection_status', 20)->default('pending')->comment('pending/completed');
$table->timestamps();
$table->index(['quality_document_id', 'inspection_status'], 'qdl_doc_id_status_index');
});
}
public function down(): void
{
Schema::dropIfExists('quality_document_locations');
}
};

View File

@@ -0,0 +1,37 @@
<?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('performance_reports', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
$table->foreignId('quality_document_id')->constrained();
$table->unsignedSmallInteger('year')->comment('연도');
$table->unsignedTinyInteger('quarter')->comment('분기 1-4');
$table->string('confirmation_status', 20)->default('unconfirmed')->comment('unconfirmed/confirmed/reported');
$table->date('confirmed_date')->nullable();
$table->foreignId('confirmed_by')->nullable()->constrained('users');
$table->text('memo')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->unsignedBigInteger('deleted_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'quality_document_id']);
$table->index(['tenant_id', 'year', 'quarter']);
$table->index(['tenant_id', 'confirmation_status']);
});
}
public function down(): void
{
Schema::dropIfExists('performance_reports');
}
};

View File

@@ -0,0 +1,22 @@
<?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::table('quality_document_locations', function (Blueprint $table) {
$table->json('inspection_data')->nullable()->after('change_reason')->comment('검사 데이터 JSON');
});
}
public function down(): void
{
Schema::table('quality_document_locations', function (Blueprint $table) {
$table->dropColumn('inspection_data');
});
}
};

40
routes/api/v1/quality.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
/**
* 품질관리 API 라우트 (v1)
*
* - 제품검사 (품질관리서)
* - 실적신고
*/
use App\Http\Controllers\Api\V1\PerformanceReportController;
use App\Http\Controllers\Api\V1\QualityDocumentController;
use Illuminate\Support\Facades\Route;
// 제품검사 (품질관리서)
Route::prefix('quality/documents')->group(function () {
Route::get('', [QualityDocumentController::class, 'index'])->name('v1.quality.documents.index');
Route::get('/stats', [QualityDocumentController::class, 'stats'])->name('v1.quality.documents.stats');
Route::get('/calendar', [QualityDocumentController::class, 'calendar'])->name('v1.quality.documents.calendar');
Route::get('/available-orders', [QualityDocumentController::class, 'availableOrders'])->name('v1.quality.documents.available-orders');
Route::post('', [QualityDocumentController::class, 'store'])->name('v1.quality.documents.store');
Route::get('/{id}', [QualityDocumentController::class, 'show'])->whereNumber('id')->name('v1.quality.documents.show');
Route::put('/{id}', [QualityDocumentController::class, 'update'])->whereNumber('id')->name('v1.quality.documents.update');
Route::delete('/{id}', [QualityDocumentController::class, 'destroy'])->whereNumber('id')->name('v1.quality.documents.destroy');
Route::patch('/{id}/complete', [QualityDocumentController::class, 'complete'])->whereNumber('id')->name('v1.quality.documents.complete');
Route::post('/{id}/orders', [QualityDocumentController::class, 'attachOrders'])->whereNumber('id')->name('v1.quality.documents.attach-orders');
Route::delete('/{id}/orders/{orderId}', [QualityDocumentController::class, 'detachOrder'])->whereNumber('id')->whereNumber('orderId')->name('v1.quality.documents.detach-order');
Route::post('/{id}/locations/{locId}/inspect', [QualityDocumentController::class, 'inspectLocation'])->whereNumber('id')->whereNumber('locId')->name('v1.quality.documents.inspect-location');
Route::get('/{id}/request-document', [QualityDocumentController::class, 'requestDocument'])->whereNumber('id')->name('v1.quality.documents.request-document');
Route::get('/{id}/result-document', [QualityDocumentController::class, 'resultDocument'])->whereNumber('id')->name('v1.quality.documents.result-document');
});
// 실적신고
Route::prefix('quality/performance-reports')->group(function () {
Route::get('', [PerformanceReportController::class, 'index'])->name('v1.quality.performance-reports.index');
Route::get('/stats', [PerformanceReportController::class, 'stats'])->name('v1.quality.performance-reports.stats');
Route::get('/missing', [PerformanceReportController::class, 'missing'])->name('v1.quality.performance-reports.missing');
Route::patch('/confirm', [PerformanceReportController::class, 'confirm'])->name('v1.quality.performance-reports.confirm');
Route::patch('/unconfirm', [PerformanceReportController::class, 'unconfirm'])->name('v1.quality.performance-reports.unconfirm');
Route::patch('/memo', [PerformanceReportController::class, 'updateMemo'])->name('v1.quality.performance-reports.memo');
});

View File

@@ -17,3 +17,17 @@
Route::get('/monthly', [StatController::class, 'monthly'])->name('v1.stats.monthly');
Route::get('/alerts', [StatController::class, 'alerts'])->name('v1.stats.alerts');
});
// 미개발 Summary API (플레이스홀더 - 오류 방지용)
$placeholderSummary = function () {
return response()->json([
'success' => true,
'message' => '해당 기능은 현재 준비 중입니다.',
'data' => null,
]);
};
Route::get('production/summary', $placeholderSummary)->name('v1.production.summary');
Route::get('construction/summary', $placeholderSummary)->name('v1.construction.summary');
Route::get('unshipped/summary', $placeholderSummary)->name('v1.unshipped.summary');
Route::get('attendance/summary', $placeholderSummary)->name('v1.attendance.summary');