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:
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php
Normal file
32
app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php
Normal 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]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,14 @@ public function documents(): MorphMany
|
||||
return $this->morphMany(Document::class, 'linkable');
|
||||
}
|
||||
|
||||
/**
|
||||
* 개소별 자재 투입 이력
|
||||
*/
|
||||
public function materialInputs(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkOrderMaterialInput::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 출하 목록
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 스코프
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
103
app/Models/Production/WorkOrderMaterialInput.php
Normal file
103
app/Models/Production/WorkOrderMaterialInput.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목별 가용 재고 조회
|
||||
*
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user