fix: MES 모델 및 서비스 개선
- WorkOrderItem 모델 관계 정의 추가 - InspectionService, WorkResultService 로직 개선 - ItemReceipt, Inspection 모델 수정 - work_order_items 테이블에 options 컬럼 추가 마이그레이션 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
103
.serena/memories/quote-bom-bulk-api-phase-1.2.md
Normal file
103
.serena/memories/quote-bom-bulk-api-phase-1.2.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Phase 1.2: 다건 BOM 기반 자동산출 API
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
- **작업일**: 2026-01-02
|
||||||
|
- **커밋**: `4e59bbf` feat: Phase 1.2 - 다건 BOM 기반 자동산출 API 구현
|
||||||
|
- **목적**: React 견적등록 화면에서 여러 품목의 자동산출 일괄 요청
|
||||||
|
|
||||||
|
## API 엔드포인트
|
||||||
|
```
|
||||||
|
POST /api/v1/quotes/calculate/bom/bulk
|
||||||
|
```
|
||||||
|
|
||||||
|
## 파일 목록
|
||||||
|
|
||||||
|
### 생성된 파일
|
||||||
|
| 파일 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest |
|
||||||
|
| `api/docs/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
|
||||||
|
|
||||||
|
### 수정된 파일
|
||||||
|
| 파일 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/Quote/QuoteCalculationService.php` | calculateBomBulk() 메서드 추가 |
|
||||||
|
| `app/Http/Controllers/Api/V1/QuoteController.php` | calculateBomBulk 액션 추가 |
|
||||||
|
| `routes/api.php` | /calculate/bom/bulk 라우트 추가 |
|
||||||
|
| `app/Swagger/v1/QuoteApi.php` | 스키마 3개 + 엔드포인트 추가 |
|
||||||
|
|
||||||
|
## React → API 필드 매핑
|
||||||
|
|
||||||
|
| React 필드 (camelCase) | API 변수 (약어) | 설명 |
|
||||||
|
|----------------------|----------------|------|
|
||||||
|
| openWidth | W0 | 개구부 폭 (mm) |
|
||||||
|
| openHeight | H0 | 개구부 높이 (mm) |
|
||||||
|
| quantity | QTY | 수량 |
|
||||||
|
| productCategory | PC | 제품 카테고리 |
|
||||||
|
| guideRailType | GT | 가이드레일 타입 |
|
||||||
|
| motorPower | MP | 모터 출력 |
|
||||||
|
| controller | CT | 제어반 |
|
||||||
|
| wingSize | WS | 날개 크기 |
|
||||||
|
| inspectionFee | INSP | 검사비 |
|
||||||
|
|
||||||
|
## 요청/응답 예시
|
||||||
|
|
||||||
|
### 요청
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"finished_goods_code": "SC-1000",
|
||||||
|
"openWidth": 3000,
|
||||||
|
"openHeight": 2500,
|
||||||
|
"quantity": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"finished_goods_code": "SC-2000",
|
||||||
|
"W0": 4000,
|
||||||
|
"H0": 3000,
|
||||||
|
"QTY": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"debug": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 응답
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "견적 일괄 산출이 완료되었습니다.",
|
||||||
|
"data": {
|
||||||
|
"success": true,
|
||||||
|
"summary": {
|
||||||
|
"total_count": 2,
|
||||||
|
"success_count": 2,
|
||||||
|
"fail_count": 0,
|
||||||
|
"grand_total": 2500000
|
||||||
|
},
|
||||||
|
"items": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 핵심 로직
|
||||||
|
|
||||||
|
### QuoteBomBulkCalculateRequest::getInputItems()
|
||||||
|
- React camelCase 필드명을 API 약어로 자동 변환
|
||||||
|
- 양쪽 필드명 모두 지원 (하위 호환성)
|
||||||
|
|
||||||
|
### QuoteCalculationService::calculateBomBulk()
|
||||||
|
- 각 품목에 대해 calculateBom() 순회 호출
|
||||||
|
- 성공/실패 카운트 집계
|
||||||
|
- 개별 품목 실패가 전체에 영향 없음 (예외 처리)
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
- 계획 문서: `docs/plans/quote-calculation-api-plan.md`
|
||||||
|
- Phase 1.1 문서: `docs/changes/20260102_quote_bom_calculation_api.md`
|
||||||
|
- Phase 1.2 문서: `docs/changes/20260102_1300_quote_bom_bulk_calculation.md`
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
- React 프론트엔드에서 `/calculate/bom/bulk` API 연동
|
||||||
|
- 실제 품목 데이터로 테스트
|
||||||
|
- 저장 플로우 연결 (`POST /quotes` store API)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 논리적 데이터베이스 관계 문서
|
# 논리적 데이터베이스 관계 문서
|
||||||
|
|
||||||
> **자동 생성**: 2026-01-13 09:55:38
|
> **자동 생성**: 2026-01-14 19:19:09
|
||||||
> **소스**: Eloquent 모델 관계 분석
|
> **소스**: Eloquent 모델 관계 분석
|
||||||
|
|
||||||
## 📊 모델별 관계 현황
|
## 📊 모델별 관계 현황
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Models\Items;
|
namespace App\Models\Items;
|
||||||
|
|
||||||
use App\Models\Scopes\BelongsToTenant;
|
use App\Models\Members\User;
|
||||||
use App\Models\Tenants\User;
|
use App\Traits\BelongsToTenant;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
*/
|
*/
|
||||||
class ItemReceipt extends Model
|
class ItemReceipt extends Model
|
||||||
{
|
{
|
||||||
use SoftDeletes;
|
use BelongsToTenant, SoftDeletes;
|
||||||
|
|
||||||
protected $table = 'item_receipts';
|
protected $table = 'item_receipts';
|
||||||
|
|
||||||
@@ -61,11 +61,6 @@ class ItemReceipt extends Model
|
|||||||
'weight_kg' => 'decimal:2',
|
'weight_kg' => 'decimal:2',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
|
||||||
{
|
|
||||||
static::addGlobalScope(new BelongsToTenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Relationships =====
|
// ===== Relationships =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class WorkOrderItem extends Model
|
|||||||
'unit',
|
'unit',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
'status',
|
'status',
|
||||||
|
'options',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +45,7 @@ class WorkOrderItem extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'quantity' => 'decimal:2',
|
'quantity' => 'decimal:2',
|
||||||
'sort_order' => 'integer',
|
'sort_order' => 'integer',
|
||||||
|
'options' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -77,4 +79,70 @@ public function scopeOrdered($query)
|
|||||||
{
|
{
|
||||||
return $query->orderBy('sort_order');
|
return $query->orderBy('sort_order');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완료된 품목만 필터
|
||||||
|
*/
|
||||||
|
public function scopeCompleted($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_COMPLETED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 결과가 있는 품목만 필터
|
||||||
|
*/
|
||||||
|
public function scopeHasResult($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('options->result');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// 헬퍼 메서드
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 결과 데이터 가져오기
|
||||||
|
*/
|
||||||
|
public function getResult(): ?array
|
||||||
|
{
|
||||||
|
return $this->options['result'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 결과 데이터 설정
|
||||||
|
*/
|
||||||
|
public function setResult(array $result): void
|
||||||
|
{
|
||||||
|
$options = $this->options ?? [];
|
||||||
|
$options['result'] = array_merge($options['result'] ?? [], $result);
|
||||||
|
$this->options = $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 완료 처리 (결과 데이터 저장)
|
||||||
|
*/
|
||||||
|
public function completeWithResult(array $resultData = []): void
|
||||||
|
{
|
||||||
|
$this->status = self::STATUS_COMPLETED;
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'completed_at' => now()->toDateTimeString(),
|
||||||
|
'good_qty' => $resultData['good_qty'] ?? $this->quantity,
|
||||||
|
'defect_qty' => $resultData['defect_qty'] ?? 0,
|
||||||
|
'lot_no' => $resultData['lot_no'] ?? null,
|
||||||
|
'is_inspected' => $resultData['is_inspected'] ?? false,
|
||||||
|
'is_packaged' => $resultData['is_packaged'] ?? false,
|
||||||
|
'worker_id' => $resultData['worker_id'] ?? null,
|
||||||
|
'memo' => $resultData['memo'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 불량률 자동 계산
|
||||||
|
$totalQty = $result['good_qty'] + $result['defect_qty'];
|
||||||
|
$result['defect_rate'] = $totalQty > 0
|
||||||
|
? round(($result['defect_qty'] / $totalQty) * 100, 2)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$this->setResult($result);
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace App\Models\Qualitys;
|
namespace App\Models\Qualitys;
|
||||||
|
|
||||||
use App\Models\Items\Item;
|
use App\Models\Items\Item;
|
||||||
use App\Models\Scopes\BelongsToTenant;
|
use App\Models\Members\User;
|
||||||
use App\Models\Tenants\User;
|
use App\Traits\BelongsToTenant;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
*/
|
*/
|
||||||
class Inspection extends Model
|
class Inspection extends Model
|
||||||
{
|
{
|
||||||
use SoftDeletes;
|
use BelongsToTenant, SoftDeletes;
|
||||||
|
|
||||||
protected $table = 'inspections';
|
protected $table = 'inspections';
|
||||||
|
|
||||||
@@ -89,11 +89,6 @@ class Inspection extends Model
|
|||||||
|
|
||||||
public const RESULT_FAIL = 'fail';
|
public const RESULT_FAIL = 'fail';
|
||||||
|
|
||||||
protected static function booted(): void
|
|
||||||
{
|
|
||||||
static::addGlobalScope(new BelongsToTenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Relationships =====
|
// ===== Relationships =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public function index(array $params)
|
|||||||
$transformedData = $paginated->getCollection()->map(fn ($item) => $this->transformToFrontend($item));
|
$transformedData = $paginated->getCollection()->map(fn ($item) => $this->transformToFrontend($item));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'data' => $transformedData,
|
'items' => $transformedData,
|
||||||
'current_page' => $paginated->currentPage(),
|
'current_page' => $paginated->currentPage(),
|
||||||
'last_page' => $paginated->lastPage(),
|
'last_page' => $paginated->lastPage(),
|
||||||
'per_page' => $paginated->perPage(),
|
'per_page' => $paginated->perPage(),
|
||||||
|
|||||||
@@ -2,15 +2,21 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Production\WorkOrder;
|
use App\Models\Production\WorkOrderItem;
|
||||||
use App\Models\Production\WorkResult;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업실적 서비스
|
||||||
|
*
|
||||||
|
* 완료된 WorkOrderItem의 options->result 데이터를 기반으로 작업실적 조회/수정
|
||||||
|
*/
|
||||||
class WorkResultService extends Service
|
class WorkResultService extends Service
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 목록 조회 (검색/필터링/페이징)
|
* 목록 조회 (검색/필터링/페이징)
|
||||||
|
*
|
||||||
|
* 완료된 WorkOrderItem에서 result 데이터가 있는 항목만 조회
|
||||||
*/
|
*/
|
||||||
public function index(array $params)
|
public function index(array $params)
|
||||||
{
|
{
|
||||||
@@ -19,7 +25,6 @@ public function index(array $params)
|
|||||||
$page = (int) ($params['page'] ?? 1);
|
$page = (int) ($params['page'] ?? 1);
|
||||||
$size = (int) ($params['size'] ?? 20);
|
$size = (int) ($params['size'] ?? 20);
|
||||||
$q = trim((string) ($params['q'] ?? ''));
|
$q = trim((string) ($params['q'] ?? ''));
|
||||||
$processType = $params['process_type'] ?? null;
|
|
||||||
$workOrderId = $params['work_order_id'] ?? null;
|
$workOrderId = $params['work_order_id'] ?? null;
|
||||||
$workerId = $params['worker_id'] ?? null;
|
$workerId = $params['worker_id'] ?? null;
|
||||||
$workDateFrom = $params['work_date_from'] ?? null;
|
$workDateFrom = $params['work_date_from'] ?? null;
|
||||||
@@ -27,27 +32,24 @@ public function index(array $params)
|
|||||||
$isInspected = isset($params['is_inspected']) ? filter_var($params['is_inspected'], FILTER_VALIDATE_BOOLEAN) : null;
|
$isInspected = isset($params['is_inspected']) ? filter_var($params['is_inspected'], FILTER_VALIDATE_BOOLEAN) : null;
|
||||||
$isPackaged = isset($params['is_packaged']) ? filter_var($params['is_packaged'], FILTER_VALIDATE_BOOLEAN) : null;
|
$isPackaged = isset($params['is_packaged']) ? filter_var($params['is_packaged'], FILTER_VALIDATE_BOOLEAN) : null;
|
||||||
|
|
||||||
$query = WorkResult::query()
|
$query = WorkOrderItem::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('status', WorkOrderItem::STATUS_COMPLETED)
|
||||||
|
->whereNotNull('options->result')
|
||||||
->with([
|
->with([
|
||||||
'workOrder:id,work_order_no',
|
'workOrder:id,work_order_no,project_name,process_id,completed_at',
|
||||||
'worker:id,name',
|
'workOrder.process:id,process_name,process_code',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 검색어
|
// 검색어 (로트번호, 품목명, 작업지시번호)
|
||||||
if ($q !== '') {
|
if ($q !== '') {
|
||||||
$query->where(function ($qq) use ($q) {
|
$query->where(function ($qq) use ($q) {
|
||||||
$qq->where('lot_no', 'like', "%{$q}%")
|
$qq->where('options->result->lot_no', 'like', "%{$q}%")
|
||||||
->orWhere('product_name', 'like', "%{$q}%")
|
->orWhere('item_name', 'like', "%{$q}%")
|
||||||
->orWhereHas('workOrder', fn ($wo) => $wo->where('work_order_no', 'like', "%{$q}%"));
|
->orWhereHas('workOrder', fn ($wo) => $wo->where('work_order_no', 'like', "%{$q}%"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 공정유형 필터
|
|
||||||
if ($processType !== null) {
|
|
||||||
$query->where('process_type', $processType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업지시 필터
|
// 작업지시 필터
|
||||||
if ($workOrderId !== null) {
|
if ($workOrderId !== null) {
|
||||||
$query->where('work_order_id', $workOrderId);
|
$query->where('work_order_id', $workOrderId);
|
||||||
@@ -55,28 +57,29 @@ public function index(array $params)
|
|||||||
|
|
||||||
// 작업자 필터
|
// 작업자 필터
|
||||||
if ($workerId !== null) {
|
if ($workerId !== null) {
|
||||||
$query->where('worker_id', $workerId);
|
$query->where('options->result->worker_id', $workerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 작업일 범위
|
// 작업일 범위 (completed_at 기준)
|
||||||
if ($workDateFrom !== null) {
|
if ($workDateFrom !== null) {
|
||||||
$query->where('work_date', '>=', $workDateFrom);
|
$query->where('options->result->completed_at', '>=', $workDateFrom);
|
||||||
}
|
}
|
||||||
if ($workDateTo !== null) {
|
if ($workDateTo !== null) {
|
||||||
$query->where('work_date', '<=', $workDateTo);
|
$query->where('options->result->completed_at', '<=', $workDateTo . ' 23:59:59');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검사 완료 필터
|
// 검사 완료 필터
|
||||||
if ($isInspected !== null) {
|
if ($isInspected !== null) {
|
||||||
$query->where('is_inspected', $isInspected);
|
$query->where('options->result->is_inspected', $isInspected);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 포장 완료 필터
|
// 포장 완료 필터
|
||||||
if ($isPackaged !== null) {
|
if ($isPackaged !== null) {
|
||||||
$query->where('is_packaged', $isPackaged);
|
$query->where('options->result->is_packaged', $isPackaged);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->orderByDesc('work_date')->orderByDesc('created_at');
|
// 최신 완료순 정렬
|
||||||
|
$query->orderByDesc('options->result->completed_at')->orderByDesc('id');
|
||||||
|
|
||||||
return $query->paginate($size, ['*'], 'page', $page);
|
return $query->paginate($size, ['*'], 'page', $page);
|
||||||
}
|
}
|
||||||
@@ -90,41 +93,44 @@ public function stats(array $params = []): array
|
|||||||
|
|
||||||
$workDateFrom = $params['work_date_from'] ?? null;
|
$workDateFrom = $params['work_date_from'] ?? null;
|
||||||
$workDateTo = $params['work_date_to'] ?? null;
|
$workDateTo = $params['work_date_to'] ?? null;
|
||||||
$processType = $params['process_type'] ?? null;
|
|
||||||
|
|
||||||
$query = WorkResult::where('tenant_id', $tenantId);
|
$query = WorkOrderItem::where('tenant_id', $tenantId)
|
||||||
|
->where('status', WorkOrderItem::STATUS_COMPLETED)
|
||||||
|
->whereNotNull('options->result');
|
||||||
|
|
||||||
// 작업일 범위
|
// 작업일 범위
|
||||||
if ($workDateFrom !== null) {
|
if ($workDateFrom !== null) {
|
||||||
$query->where('work_date', '>=', $workDateFrom);
|
$query->where('options->result->completed_at', '>=', $workDateFrom);
|
||||||
}
|
}
|
||||||
if ($workDateTo !== null) {
|
if ($workDateTo !== null) {
|
||||||
$query->where('work_date', '<=', $workDateTo);
|
$query->where('options->result->completed_at', '<=', $workDateTo . ' 23:59:59');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 공정유형 필터
|
// JSON에서 집계 (MySQL/MariaDB 기준)
|
||||||
if ($processType !== null) {
|
$items = $query->get();
|
||||||
$query->where('process_type', $processType);
|
|
||||||
|
$totalProduction = 0;
|
||||||
|
$totalGood = 0;
|
||||||
|
$totalDefect = 0;
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$result = $item->options['result'] ?? [];
|
||||||
|
$goodQty = (float) ($result['good_qty'] ?? 0);
|
||||||
|
$defectQty = (float) ($result['defect_qty'] ?? 0);
|
||||||
|
|
||||||
|
$totalGood += $goodQty;
|
||||||
|
$totalDefect += $defectQty;
|
||||||
|
$totalProduction += ($goodQty + $defectQty);
|
||||||
}
|
}
|
||||||
|
|
||||||
$totals = $query->select([
|
|
||||||
DB::raw('SUM(production_qty) as total_production'),
|
|
||||||
DB::raw('SUM(good_qty) as total_good'),
|
|
||||||
DB::raw('SUM(defect_qty) as total_defect'),
|
|
||||||
])->first();
|
|
||||||
|
|
||||||
$totalProduction = (int) ($totals->total_production ?? 0);
|
|
||||||
$totalGood = (int) ($totals->total_good ?? 0);
|
|
||||||
$totalDefect = (int) ($totals->total_defect ?? 0);
|
|
||||||
|
|
||||||
$defectRate = $totalProduction > 0
|
$defectRate = $totalProduction > 0
|
||||||
? round(($totalDefect / $totalProduction) * 100, 1)
|
? round(($totalDefect / $totalProduction) * 100, 1)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'total_production' => $totalProduction,
|
'total_production' => (int) $totalProduction,
|
||||||
'total_good' => $totalGood,
|
'total_good' => (int) $totalGood,
|
||||||
'total_defect' => $totalDefect,
|
'total_defect' => (int) $totalDefect,
|
||||||
'defect_rate' => $defectRate,
|
'defect_rate' => $defectRate,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -136,123 +142,92 @@ public function show(int $id)
|
|||||||
{
|
{
|
||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
|
|
||||||
$workResult = WorkResult::where('tenant_id', $tenantId)
|
$item = WorkOrderItem::where('tenant_id', $tenantId)
|
||||||
|
->where('status', WorkOrderItem::STATUS_COMPLETED)
|
||||||
|
->whereNotNull('options->result')
|
||||||
->with([
|
->with([
|
||||||
'workOrder:id,work_order_no,project_name,status',
|
'workOrder:id,work_order_no,project_name,process_id,completed_at',
|
||||||
'workOrderItem:id,item_name,specification,quantity',
|
'workOrder.process:id,process_name,process_code',
|
||||||
'worker:id,name',
|
|
||||||
])
|
])
|
||||||
->find($id);
|
->find($id);
|
||||||
|
|
||||||
if (! $workResult) {
|
if (! $item) {
|
||||||
throw new NotFoundHttpException(__('error.not_found'));
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $workResult;
|
return $item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 생성
|
* 작업실적 수정 (양품/불량 수량 등)
|
||||||
*/
|
|
||||||
public function store(array $data)
|
|
||||||
{
|
|
||||||
$tenantId = $this->tenantId();
|
|
||||||
$userId = $this->apiUserId();
|
|
||||||
|
|
||||||
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
|
||||||
$data['tenant_id'] = $tenantId;
|
|
||||||
$data['created_by'] = $userId;
|
|
||||||
$data['updated_by'] = $userId;
|
|
||||||
|
|
||||||
// 양품수량 자동 계산 (입력 안 된 경우)
|
|
||||||
if (! isset($data['good_qty'])) {
|
|
||||||
$data['good_qty'] = max(0, ($data['production_qty'] ?? 0) - ($data['defect_qty'] ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 작업지시 정보로 자동 채움
|
|
||||||
if (! empty($data['work_order_id'])) {
|
|
||||||
$workOrder = WorkOrder::find($data['work_order_id']);
|
|
||||||
if ($workOrder) {
|
|
||||||
$data['process_type'] = $data['process_type'] ?? $workOrder->process_type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return WorkResult::create($data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수정
|
|
||||||
*/
|
*/
|
||||||
public function update(int $id, array $data)
|
public function update(int $id, array $data)
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
$userId = $this->apiUserId();
|
|
||||||
|
|
||||||
$workResult = WorkResult::where('tenant_id', $tenantId)->find($id);
|
$item = WorkOrderItem::where('tenant_id', $tenantId)
|
||||||
|
->where('status', WorkOrderItem::STATUS_COMPLETED)
|
||||||
|
->whereNotNull('options->result')
|
||||||
|
->find($id);
|
||||||
|
|
||||||
if (! $workResult) {
|
if (! $item) {
|
||||||
throw new NotFoundHttpException(__('error.not_found'));
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($workResult, $data, $userId) {
|
return DB::transaction(function () use ($item, $data) {
|
||||||
$data['updated_by'] = $userId;
|
$options = $item->options ?? [];
|
||||||
|
$result = $options['result'] ?? [];
|
||||||
|
|
||||||
// 양품수량 재계산 (생산수량 또는 불량수량 변경 시)
|
// 수정 가능한 필드만 업데이트
|
||||||
if (isset($data['production_qty']) || isset($data['defect_qty'])) {
|
$allowedFields = ['good_qty', 'defect_qty', 'lot_no', 'is_inspected', 'is_packaged', 'memo'];
|
||||||
$productionQty = $data['production_qty'] ?? $workResult->production_qty;
|
|
||||||
$defectQty = $data['defect_qty'] ?? $workResult->defect_qty;
|
|
||||||
|
|
||||||
if (! isset($data['good_qty'])) {
|
foreach ($allowedFields as $field) {
|
||||||
$data['good_qty'] = max(0, $productionQty - $defectQty);
|
if (array_key_exists($field, $data)) {
|
||||||
|
$result[$field] = $data[$field];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$workResult->update($data);
|
// 불량률 재계산
|
||||||
|
$totalQty = ($result['good_qty'] ?? 0) + ($result['defect_qty'] ?? 0);
|
||||||
|
$result['defect_rate'] = $totalQty > 0
|
||||||
|
? round(($result['defect_qty'] / $totalQty) * 100, 2)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return $workResult->fresh([
|
$options['result'] = $result;
|
||||||
'workOrder:id,work_order_no',
|
$item->options = $options;
|
||||||
'worker:id,name',
|
$item->save();
|
||||||
|
|
||||||
|
return $item->fresh([
|
||||||
|
'workOrder:id,work_order_no,project_name,process_id',
|
||||||
|
'workOrder.process:id,process_name,process_code',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 삭제
|
|
||||||
*/
|
|
||||||
public function destroy(int $id): void
|
|
||||||
{
|
|
||||||
$tenantId = $this->tenantId();
|
|
||||||
|
|
||||||
$workResult = WorkResult::where('tenant_id', $tenantId)->find($id);
|
|
||||||
|
|
||||||
if (! $workResult) {
|
|
||||||
throw new NotFoundHttpException(__('error.not_found'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$workResult->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 검사 상태 토글
|
* 검사 상태 토글
|
||||||
*/
|
*/
|
||||||
public function toggleInspection(int $id)
|
public function toggleInspection(int $id)
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
$userId = $this->apiUserId();
|
|
||||||
|
|
||||||
$workResult = WorkResult::where('tenant_id', $tenantId)->find($id);
|
$item = WorkOrderItem::where('tenant_id', $tenantId)
|
||||||
|
->where('status', WorkOrderItem::STATUS_COMPLETED)
|
||||||
|
->whereNotNull('options->result')
|
||||||
|
->find($id);
|
||||||
|
|
||||||
if (! $workResult) {
|
if (! $item) {
|
||||||
throw new NotFoundHttpException(__('error.not_found'));
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$workResult->update([
|
$options = $item->options ?? [];
|
||||||
'is_inspected' => ! $workResult->is_inspected,
|
$result = $options['result'] ?? [];
|
||||||
'updated_by' => $userId,
|
$result['is_inspected'] = ! ($result['is_inspected'] ?? false);
|
||||||
]);
|
$options['result'] = $result;
|
||||||
|
$item->options = $options;
|
||||||
|
$item->save();
|
||||||
|
|
||||||
return $workResult->fresh();
|
return $item->fresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,19 +236,23 @@ public function toggleInspection(int $id)
|
|||||||
public function togglePackaging(int $id)
|
public function togglePackaging(int $id)
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
$userId = $this->apiUserId();
|
|
||||||
|
|
||||||
$workResult = WorkResult::where('tenant_id', $tenantId)->find($id);
|
$item = WorkOrderItem::where('tenant_id', $tenantId)
|
||||||
|
->where('status', WorkOrderItem::STATUS_COMPLETED)
|
||||||
|
->whereNotNull('options->result')
|
||||||
|
->find($id);
|
||||||
|
|
||||||
if (! $workResult) {
|
if (! $item) {
|
||||||
throw new NotFoundHttpException(__('error.not_found'));
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$workResult->update([
|
$options = $item->options ?? [];
|
||||||
'is_packaged' => ! $workResult->is_packaged,
|
$result = $options['result'] ?? [];
|
||||||
'updated_by' => $userId,
|
$result['is_packaged'] = ! ($result['is_packaged'] ?? false);
|
||||||
]);
|
$options['result'] = $result;
|
||||||
|
$item->options = $options;
|
||||||
|
$item->save();
|
||||||
|
|
||||||
return $workResult->fresh();
|
return $item->fresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('work_order_items', function (Blueprint $table) {
|
||||||
|
$table->json('options')->nullable()->after('status')->comment('작업 결과 등 추가 정보 (JSON)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('work_order_items', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('options');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user