feat:개소별 자재 투입 관리 API 추가

- work_order_material_inputs 테이블 신규 생성 (개소별 자재 투입 추적)
- 개소별 자재 조회/투입/이력/삭제/수정 API 5개 추가
- StockService.increaseToLot: LOT 수량 복원 메서드 추가
- WorkOrderService에 개소별 자재 투입 비즈니스 로직 구현
- WorkOrder, WorkOrderItem 모델에 materialInputs 관계 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 03:41:35 +09:00
parent d730c2d91a
commit e4c53c7b17
9 changed files with 872 additions and 70 deletions

View File

@@ -4,6 +4,7 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\WorkOrder\MaterialInputForItemRequest;
use App\Http\Requests\WorkOrder\StoreItemInspectionRequest;
use App\Http\Requests\WorkOrder\WorkOrderAssignRequest;
use App\Http\Requests\WorkOrder\WorkOrderIssueRequest;
@@ -288,4 +289,56 @@ public function createWorkLog(Request $request, int $id)
return $this->service->createWorkLog($id, $request->all());
}, __('message.work_order.work_log_saved'));
}
// ──────────────────────────────────────────────────────────────
// 개소별 자재 투입
// ──────────────────────────────────────────────────────────────
/**
* 개소별 자재 목록 조회
*/
public function materialsForItem(int $id, int $itemId)
{
return ApiResponse::handle(function () use ($id, $itemId) {
return $this->service->getMaterialsForItem($id, $itemId);
}, __('message.work_order.materials_fetched'));
}
/**
* 개소별 자재 투입 등록
*/
public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId)
{
return ApiResponse::handle(function () use ($request, $id, $itemId) {
return $this->service->registerMaterialInputForItem($id, $itemId, $request->validated()['inputs']);
}, __('message.work_order.material_input_registered'));
}
/**
* 개소별 자재 투입 이력 조회
*/
public function materialInputsForItem(int $id, int $itemId)
{
return ApiResponse::handle(function () use ($id, $itemId) {
return $this->service->getMaterialInputsForItem($id, $itemId);
}, __('message.work_order.fetched'));
}
public function deleteMaterialInput(int $id, int $inputId)
{
return ApiResponse::handle(function () use ($id, $inputId) {
$this->service->deleteMaterialInput($id, $inputId);
}, __('message.work_order.deleted'));
}
public function updateMaterialInput(Request $request, int $id, int $inputId)
{
$data = $request->validate([
'qty' => ['required', 'numeric', 'gt:0'],
]);
return ApiResponse::handle(function () use ($id, $inputId, $data) {
return $this->service->updateMaterialInput($id, $inputId, (float) $data['qty']);
}, __('message.work_order.updated'));
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\WorkOrder;
use Illuminate\Foundation\Http\FormRequest;
class MaterialInputForItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'inputs' => 'required|array|min:1',
'inputs.*.stock_lot_id' => 'required|integer',
'inputs.*.qty' => 'required|numeric|gt:0',
];
}
public function messages(): array
{
return [
'inputs.required' => __('error.validation.required', ['attribute' => '투입 목록']),
'inputs.*.stock_lot_id.required' => __('error.validation.required', ['attribute' => 'LOT ID']),
'inputs.*.qty.required' => __('error.validation.required', ['attribute' => '수량']),
'inputs.*.qty.gt' => __('error.validation.gt', ['attribute' => '수량', 'value' => 0]),
];
}
}

View File

@@ -213,6 +213,14 @@ public function documents(): MorphMany
return $this->morphMany(Document::class, 'linkable');
}
/**
* 개소별 자재 투입 이력
*/
public function materialInputs(): HasMany
{
return $this->hasMany(WorkOrderMaterialInput::class);
}
/**
* 출하 목록
*/

View File

@@ -8,6 +8,7 @@
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 작업지시 품목 모델
@@ -81,6 +82,14 @@ public function sourceOrderItem(): BelongsTo
return $this->belongsTo(OrderItem::class, 'source_order_item_id');
}
/**
* 자재 투입 이력
*/
public function materialInputs(): HasMany
{
return $this->hasMany(WorkOrderMaterialInput::class);
}
// ──────────────────────────────────────────────────────────────
// 스코프
// ──────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Models\Production;
use App\Models\Items\Item;
use App\Models\Members\User;
use App\Models\Tenants\StockLot;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 개소별 자재 투입 이력 모델
*
* 작업지시 품목(개소)별 자재 투입을 추적
*/
class WorkOrderMaterialInput extends Model
{
use Auditable, BelongsToTenant;
protected $table = 'work_order_material_inputs';
protected $fillable = [
'tenant_id',
'work_order_id',
'work_order_item_id',
'stock_lot_id',
'item_id',
'qty',
'input_by',
'input_at',
];
protected $casts = [
'qty' => 'decimal:3',
'input_at' => 'datetime',
];
// ──────────────────────────────────────────────────────────────
// 관계
// ──────────────────────────────────────────────────────────────
/**
* 작업지시
*/
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
/**
* 작업지시 품목 (개소)
*/
public function workOrderItem(): BelongsTo
{
return $this->belongsTo(WorkOrderItem::class);
}
/**
* 투입 로트
*/
public function stockLot(): BelongsTo
{
return $this->belongsTo(StockLot::class);
}
/**
* 자재 품목
*/
public function item(): BelongsTo
{
return $this->belongsTo(Item::class);
}
/**
* 투입자
*/
public function inputBy(): BelongsTo
{
return $this->belongsTo(User::class, 'input_by');
}
// ──────────────────────────────────────────────────────────────
// 스코프
// ──────────────────────────────────────────────────────────────
/**
* 특정 개소의 투입 이력
*/
public function scopeForItem($query, int $workOrderItemId)
{
return $query->where('work_order_item_id', $workOrderItemId);
}
/**
* 특정 자재의 투입 이력
*/
public function scopeForMaterial($query, int $itemId)
{
return $query->where('item_id', $itemId);
}
}

View File

@@ -703,6 +703,90 @@ public function decreaseFromLot(int $stockLotId, float $qty, string $reason, int
});
}
/**
* 특정 LOT에 수량 복원 (투입 취소, 삭제 등)
* decreaseFromLot의 역방향
*/
public function increaseToLot(int $stockLotId, float $qty, string $reason, int $referenceId): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($stockLotId, $qty, $reason, $referenceId, $tenantId, $userId) {
$lot = StockLot::where('tenant_id', $tenantId)
->where('id', $stockLotId)
->lockForUpdate()
->first();
if (! $lot) {
throw new \Exception(__('error.stock.lot_not_available'));
}
$stock = Stock::where('id', $lot->stock_id)
->lockForUpdate()
->first();
if (! $stock) {
throw new \Exception(__('error.stock.not_found'));
}
$oldStockQty = $stock->stock_qty;
// LOT 수량 복원
$lot->qty += $qty;
$lot->available_qty += $qty;
$lot->updated_by = $userId;
if ($lot->status === 'used' && $lot->qty > 0) {
$lot->status = 'available';
}
$lot->save();
// Stock 정보 갱신
$stock->refreshFromLots();
// 거래 이력 기록
$this->recordTransaction(
stock: $stock,
type: StockTransaction::TYPE_IN,
qty: $qty,
reason: $reason,
referenceType: $reason,
referenceId: $referenceId,
lotNo: $lot->lot_no,
stockLotId: $lot->id
);
// 감사 로그
$this->logStockChange(
stock: $stock,
action: 'stock_increase',
reason: $reason,
referenceType: $reason,
referenceId: $referenceId,
qtyChange: $qty,
lotNo: $lot->lot_no
);
Log::info('Stock increased to specific lot', [
'stock_lot_id' => $stockLotId,
'lot_no' => $lot->lot_no,
'qty' => $qty,
'reason' => $reason,
'reference_id' => $referenceId,
'old_stock_qty' => $oldStockQty,
'new_stock_qty' => $stock->stock_qty,
]);
return [
'lot_id' => $lot->id,
'lot_no' => $lot->lot_no,
'restored_qty' => $qty,
'remaining_qty' => $lot->qty,
];
});
}
/**
* 품목별 가용 재고 조회
*

View File

@@ -9,6 +9,7 @@
use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail;
use App\Models\Production\WorkOrderItem;
use App\Models\Production\WorkOrderMaterialInput;
use App\Models\Production\WorkOrderStepProgress;
use App\Models\Tenants\Shipment;
use App\Models\Tenants\ShipmentItem;
@@ -58,6 +59,9 @@ public function index(array $params)
'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id',
'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code',
'items.sourceOrderItem.node:id,name,code',
'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at',
'items.materialInputs.stockLot:id,lot_no',
'items.materialInputs.item:id,code,name,unit',
]);
// 검색어
@@ -1418,7 +1422,10 @@ public function getStepProgress(int $workOrderId): array
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order')])
->with([
'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'),
'items',
])
->find($workOrderId);
if (! $workOrder) {
@@ -1430,41 +1437,85 @@ public function getStepProgress(int $workOrderId): array
return [];
}
// 기존 진행 레코드 조회
$existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId)
->whereNull('work_order_item_id')
->get()
->keyBy('process_step_id');
// 없는 단계는 자동 생성
$items = $workOrder->items;
$result = [];
foreach ($processSteps as $step) {
if ($existingProgress->has($step->id)) {
$progress = $existingProgress->get($step->id);
} else {
$progress = WorkOrderStepProgress::create([
'tenant_id' => $tenantId,
'work_order_id' => $workOrderId,
if ($items->isNotEmpty()) {
// 개소(item)별 진행 레코드 생성/조회
$existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId)
->whereNotNull('work_order_item_id')
->get()
->groupBy('work_order_item_id');
foreach ($items as $item) {
$itemProgress = ($existingProgress->get($item->id) ?? collect())->keyBy('process_step_id');
foreach ($processSteps as $step) {
if ($itemProgress->has($step->id)) {
$progress = $itemProgress->get($step->id);
} else {
$progress = WorkOrderStepProgress::create([
'tenant_id' => $tenantId,
'work_order_id' => $workOrderId,
'process_step_id' => $step->id,
'work_order_item_id' => $item->id,
'status' => WorkOrderStepProgress::STATUS_WAITING,
]);
}
$result[] = [
'id' => $progress->id,
'process_step_id' => $step->id,
'work_order_item_id' => $item->id,
'step_code' => $step->step_code,
'step_name' => $step->step_name,
'sort_order' => $step->sort_order,
'needs_inspection' => $step->needs_inspection,
'connection_type' => $step->connection_type,
'completion_type' => $step->completion_type,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
];
}
}
} else {
// items 없으면 작업지시 전체 레벨 (기존 동작)
$existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId)
->whereNull('work_order_item_id')
->get()
->keyBy('process_step_id');
foreach ($processSteps as $step) {
if ($existingProgress->has($step->id)) {
$progress = $existingProgress->get($step->id);
} else {
$progress = WorkOrderStepProgress::create([
'tenant_id' => $tenantId,
'work_order_id' => $workOrderId,
'process_step_id' => $step->id,
'work_order_item_id' => null,
'status' => WorkOrderStepProgress::STATUS_WAITING,
]);
}
$result[] = [
'id' => $progress->id,
'process_step_id' => $step->id,
'work_order_item_id' => null,
'status' => WorkOrderStepProgress::STATUS_WAITING,
]);
'step_code' => $step->step_code,
'step_name' => $step->step_name,
'sort_order' => $step->sort_order,
'needs_inspection' => $step->needs_inspection,
'connection_type' => $step->connection_type,
'completion_type' => $step->completion_type,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
];
}
$result[] = [
'id' => $progress->id,
'process_step_id' => $step->id,
'step_code' => $step->step_code,
'step_name' => $step->step_name,
'sort_order' => $step->sort_order,
'needs_inspection' => $step->needs_inspection,
'connection_type' => $step->connection_type,
'completion_type' => $step->completion_type,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
];
}
return $result;
@@ -1813,7 +1864,7 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['process'])
->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')])
->find($workOrderId);
if (! $workOrder) {
@@ -1840,16 +1891,35 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
->latest()
->first();
// 프론트 InspectionData를 document_data 정규화 레코드 형식으로 변환
$documentDataRecords = $this->transformInspectionDataToDocumentRecords(
$inspectionData['data'] ?? [],
$templateId
);
// ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집
$rawItems = [];
foreach ($workOrder->items as $item) {
$inspData = $item->getInspectionData();
if ($inspData) {
$rawItems[] = $inspData;
}
}
$documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId);
// 기존 문서의 기본필드(bf_*) 보존
if ($existingDocument) {
$existingBasicFields = $existingDocument->data()
->whereNull('section_id')
->where('field_key', 'LIKE', 'bf_%')
->get()
->map(fn ($d) => [
'section_id' => null,
'column_id' => null,
'row_index' => $d->row_index,
'field_key' => $d->field_key,
'field_value' => $d->field_value,
])
->toArray();
$document = $documentService->update($existingDocument->id, [
'title' => $inspectionData['title'] ?? $existingDocument->title,
'data' => $documentDataRecords,
'data' => array_merge($existingBasicFields, $documentDataRecords),
]);
$action = 'inspection_document_updated';
@@ -2303,7 +2373,7 @@ public function createWorkLog(int $workOrderId, array $workLogData): array
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['process', 'salesOrder:id,order_no,client_name,site_name'])
->with(['process', 'salesOrder:id,order_no,client_name,site_name', 'items'])
->find($workOrderId);
if (! $workOrder) {
@@ -2317,6 +2387,9 @@ public function createWorkLog(int $workOrderId, array $workLogData): array
throw new BadRequestHttpException(__('error.work_order.no_work_log_template'));
}
// 템플릿의 기본필드 로드 (bf_{id} 형식으로 저장하기 위해)
$template = DocumentTemplate::with('basicFields')->find($templateId);
$documentService = app(DocumentService::class);
// 기존 DRAFT/REJECTED 문서 확인
@@ -2330,7 +2403,7 @@ public function createWorkLog(int $workOrderId, array $workLogData): array
->first();
// 작업일지 데이터를 document_data 레코드로 변환
$documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder);
$documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder, $template);
if ($existingDocument) {
$document = $documentService->update($existingDocument->id, [
@@ -2375,20 +2448,31 @@ private function buildWorkLogAutoValues(WorkOrder $workOrder): array
{
$salesOrder = $workOrder->salesOrder;
// 수주일: received_at (date 또는 datetime)
$receivedAt = $salesOrder?->received_at;
$orderDate = $receivedAt ? substr((string) $receivedAt, 0, 10) : '';
// 납기일/출고예정일
$deliveryDate = $salesOrder?->delivery_date;
$deliveryStr = $deliveryDate ? substr((string) $deliveryDate, 0, 10) : '';
// 제품 LOT NO = 수주번호 (order_no)
$orderNo = $salesOrder?->order_no ?? '';
return [
'발주처' => $salesOrder?->client_name ?? '',
'현장명' => $salesOrder?->site_name ?? '',
'작업일자' => now()->format('Y-m-d'),
'LOT NO' => '',
'납기일' => $salesOrder?->delivery_date?->format('Y-m-d') ?? '',
'LOT NO' => $orderNo,
'납기일' => $deliveryStr,
'작업지시번호' => $workOrder->work_order_no ?? '',
'수주일' => $salesOrder?->order_date ?? '',
'수주일' => $orderDate,
'수주처' => $salesOrder?->client_name ?? '',
'담당자' => '',
'연락처' => '',
'제품 LOT NO' => '',
'제품 LOT NO' => $orderNo,
'생산담당자' => '',
'출고예정일' => $salesOrder?->delivery_date?->format('Y-m-d') ?? '',
'출고예정일' => $deliveryStr,
];
}
@@ -2426,37 +2510,39 @@ private function calculateWorkStats(WorkOrder $workOrder): array
/**
* 작업일지 데이터를 document_data 레코드로 변환
*
* 입력 형식:
* basic_data: { '발주처': '...', '현장명': '...' }
* table_data: [{ item_name, floor_code, specification, quantity, status }]
* remarks: "특이사항"
* mng show.blade.php 호환 형식:
* 기본필드: field_key = 'bf_{basicField->id}' (template basicFields 기반)
* 통계/비고: field_key = 'stats_*', 'remarks'
*
* auto_values로 기본필드 자동 채움, basic_data로 수동 override 가능
*/
private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $workOrder): array
private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $workOrder, ?DocumentTemplate $template): array
{
$records = [];
// 1. 기본필드 저장
$basicData = $workLogData['basic_data'] ?? [];
foreach ($basicData as $key => $value) {
$records[] = [
'field_key' => "basic_{$key}",
'field_value' => $value,
];
}
// 1. 기본필드: bf_{id} 형식으로 저장 (mng show.blade.php 호환)
if ($template && $template->basicFields) {
$autoValues = $this->buildWorkLogAutoValues($workOrder);
$manualData = $workLogData['basic_data'] ?? [];
// 2. 품목 테이블 데이터 (WorkOrderItem 기반)
$tableData = $workLogData['table_data'] ?? [];
foreach ($tableData as $index => $row) {
foreach ($row as $fieldKey => $fieldValue) {
$records[] = [
'row_index' => $index,
'field_key' => "item_{$fieldKey}",
'field_value' => is_array($fieldValue) ? json_encode($fieldValue) : (string) $fieldValue,
];
foreach ($template->basicFields as $field) {
// 수동 입력 우선, 없으면 auto_values에서 라벨로 매칭
$value = $manualData[$field->label]
?? $manualData[$field->field_key ?? '']
?? $autoValues[$field->label]
?? $field->default_value
?? '';
if ($value !== '') {
$records[] = [
'field_key' => "bf_{$field->id}",
'field_value' => (string) $value,
];
}
}
}
// 3. 작업 통계 (자동 계산)
// 2. 작업 통계 (자동 계산)
$stats = $this->calculateWorkStats($workOrder);
foreach ($stats as $key => $value) {
$records[] = [
@@ -2465,7 +2551,7 @@ private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $wo
];
}
// 4. 특이사항
// 3. 특이사항
if (isset($workLogData['remarks'])) {
$records[] = [
'field_key' => 'remarks',
@@ -2475,4 +2561,390 @@ private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $wo
return $records;
}
// ──────────────────────────────────────────────────────────────
// 개소별 자재 투입
// ──────────────────────────────────────────────────────────────
/**
* 개소별 BOM 기반 필요 자재 + 재고 LOT 조회
*/
public function getMaterialsForItem(int $workOrderId, int $itemId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$woItem = WorkOrderItem::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->with('item')
->find($itemId);
if (! $woItem) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 해당 개소의 BOM 기반 자재 추출
$materialItems = [];
if ($woItem->item_id) {
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($woItem->item_id);
if ($item && ! empty($item->bom)) {
foreach ($item->bom as $bomItem) {
$childItemId = $bomItem['child_item_id'] ?? null;
$bomQty = (float) ($bomItem['qty'] ?? 1);
if (! $childItemId) {
continue;
}
$childItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($childItemId);
if (! $childItem) {
continue;
}
$materialItems[] = [
'item' => $childItem,
'bom_qty' => $bomQty,
'required_qty' => $bomQty * ($woItem->quantity ?? 1),
];
}
}
}
// BOM이 없으면 품목 자체를 자재로 사용
if (empty($materialItems) && $woItem->item_id && $woItem->item) {
$materialItems[] = [
'item' => $woItem->item,
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
];
}
// 이미 투입된 수량 조회 (item_id별 SUM)
$inputtedQties = WorkOrderMaterialInput::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->where('work_order_item_id', $itemId)
->selectRaw('item_id, SUM(qty) as total_qty')
->groupBy('item_id')
->pluck('total_qty', 'item_id');
// 자재별 LOT 조회
$materials = [];
$rank = 1;
foreach ($materialItems as $matInfo) {
$materialItem = $matInfo['item'];
$alreadyInputted = (float) ($inputtedQties[$materialItem->id] ?? 0);
$remainingRequired = max(0, $matInfo['required_qty'] - $alreadyInputted);
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->where('item_id', $materialItem->id)
->first();
$lotsFound = false;
if ($stock) {
$lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
->where('stock_id', $stock->id)
->where('status', 'available')
->where('available_qty', '>', 0)
->orderBy('fifo_order', 'asc')
->get();
foreach ($lots as $lot) {
$lotsFound = true;
$materials[] = [
'stock_lot_id' => $lot->id,
'item_id' => $materialItem->id,
'lot_no' => $lot->lot_no,
'material_code' => $materialItem->code,
'material_name' => $materialItem->name,
'specification' => $materialItem->specification,
'unit' => $lot->unit ?? $materialItem->unit ?? 'EA',
'bom_qty' => $matInfo['bom_qty'],
'required_qty' => $matInfo['required_qty'],
'already_inputted' => $alreadyInputted,
'remaining_required_qty' => $remainingRequired,
'lot_qty' => (float) $lot->qty,
'lot_available_qty' => (float) $lot->available_qty,
'lot_reserved_qty' => (float) $lot->reserved_qty,
'receipt_date' => $lot->receipt_date,
'supplier' => $lot->supplier,
'fifo_rank' => $rank++,
];
}
}
if (! $lotsFound) {
$materials[] = [
'stock_lot_id' => null,
'item_id' => $materialItem->id,
'lot_no' => null,
'material_code' => $materialItem->code,
'material_name' => $materialItem->name,
'specification' => $materialItem->specification,
'unit' => $materialItem->unit ?? 'EA',
'bom_qty' => $matInfo['bom_qty'],
'required_qty' => $matInfo['required_qty'],
'already_inputted' => $alreadyInputted,
'remaining_required_qty' => $remainingRequired,
'lot_qty' => 0,
'lot_available_qty' => 0,
'lot_reserved_qty' => 0,
'receipt_date' => null,
'supplier' => null,
'fifo_rank' => $rank++,
];
}
}
return $materials;
}
/**
* 개소별 자재 투입 등록
*/
public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$woItem = WorkOrderItem::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->find($itemId);
if (! $woItem) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId) {
$stockService = app(StockService::class);
$inputResults = [];
foreach ($inputs as $input) {
$stockLotId = $input['stock_lot_id'] ?? null;
$qty = (float) ($input['qty'] ?? 0);
if (! $stockLotId || $qty <= 0) {
continue;
}
// 기존 재고 차감 로직 재사용
$result = $stockService->decreaseFromLot(
stockLotId: $stockLotId,
qty: $qty,
reason: 'work_order_input',
referenceId: $workOrderId
);
// 로트의 품목 ID 조회
$lot = \App\Models\Tenants\StockLot::find($stockLotId);
$lotItemId = $lot ? ($lot->stock->item_id ?? null) : null;
// 개소별 매핑 레코드 생성
WorkOrderMaterialInput::create([
'tenant_id' => $tenantId,
'work_order_id' => $workOrderId,
'work_order_item_id' => $itemId,
'stock_lot_id' => $stockLotId,
'item_id' => $lotItemId ?? 0,
'qty' => $qty,
'input_by' => $userId,
'input_at' => now(),
]);
$inputResults[] = [
'stock_lot_id' => $stockLotId,
'qty' => $qty,
'status' => 'success',
'deducted_lot' => $result,
];
}
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'material_input_for_item',
null,
[
'work_order_item_id' => $itemId,
'inputs' => $inputs,
'input_results' => $inputResults,
'input_by' => $userId,
'input_at' => now()->toDateTimeString(),
]
);
return [
'work_order_id' => $workOrderId,
'work_order_item_id' => $itemId,
'material_count' => count($inputResults),
'input_results' => $inputResults,
'input_at' => now()->toDateTimeString(),
];
});
}
/**
* 개소별 자재 투입 이력 조회
*/
public function getMaterialInputsForItem(int $workOrderId, int $itemId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$woItem = WorkOrderItem::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->find($itemId);
if (! $woItem) {
throw new NotFoundHttpException(__('error.not_found'));
}
$inputs = WorkOrderMaterialInput::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->where('work_order_item_id', $itemId)
->with(['stockLot', 'item', 'inputBy'])
->orderBy('input_at', 'desc')
->get();
return $inputs->map(function ($input) {
return [
'id' => $input->id,
'stock_lot_id' => $input->stock_lot_id,
'lot_no' => $input->stockLot?->lot_no,
'item_id' => $input->item_id,
'material_code' => $input->item?->code,
'material_name' => $input->item?->name,
'qty' => (float) $input->qty,
'unit' => $input->item?->unit ?? 'EA',
'input_by' => $input->input_by,
'input_by_name' => $input->inputBy?->name,
'input_at' => $input->input_at?->toDateTimeString(),
];
})->toArray();
}
/**
* 개소별 자재 투입 삭제 (재고 복원)
*/
public function deleteMaterialInput(int $workOrderId, int $inputId): void
{
$tenantId = $this->tenantId();
$input = WorkOrderMaterialInput::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->find($inputId);
if (! $input) {
throw new NotFoundHttpException(__('error.not_found'));
}
DB::transaction(function () use ($input, $tenantId, $workOrderId) {
// 재고 복원
$stockService = app(StockService::class);
$stockService->increaseToLot(
stockLotId: $input->stock_lot_id,
qty: (float) $input->qty,
reason: 'work_order_input_cancel',
referenceId: $workOrderId
);
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'material_input_deleted',
[
'input_id' => $input->id,
'stock_lot_id' => $input->stock_lot_id,
'qty' => (float) $input->qty,
'work_order_item_id' => $input->work_order_item_id,
],
null
);
$input->delete();
});
}
/**
* 개소별 자재 투입 수량 수정 (재고 차이 반영)
*/
public function updateMaterialInput(int $workOrderId, int $inputId, float $newQty): array
{
$tenantId = $this->tenantId();
$input = WorkOrderMaterialInput::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->find($inputId);
if (! $input) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($input, $newQty, $tenantId, $workOrderId) {
$oldQty = (float) $input->qty;
$diff = $newQty - $oldQty;
if (abs($diff) < 0.001) {
return ['id' => $input->id, 'qty' => $oldQty, 'changed' => false];
}
$stockService = app(StockService::class);
if ($diff > 0) {
// 수량 증가 → 추가 차감
$stockService->decreaseFromLot(
stockLotId: $input->stock_lot_id,
qty: $diff,
reason: 'work_order_input',
referenceId: $workOrderId
);
} else {
// 수량 감소 → 차이만큼 복원
$stockService->increaseToLot(
stockLotId: $input->stock_lot_id,
qty: abs($diff),
reason: 'work_order_input_adjust',
referenceId: $workOrderId
);
}
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'material_input_updated',
['input_id' => $input->id, 'qty' => $oldQty],
['input_id' => $input->id, 'qty' => $newQty]
);
$input->qty = $newQty;
$input->save();
return ['id' => $input->id, 'qty' => $newQty, 'changed' => true];
});
}
}