feat(WEB): 절곡품 선생산→재고적재 Phase 1 - 생산입고 기반 구축
- StockTransaction: REASON_PRODUCTION_OUTPUT 상수 및 '생산입고' 라벨 추가 - StockLot: work_order_id FK 컬럼 마이그레이션 + 모델 fillable/casts/relation 추가 - StockService: increaseFromProduction() 메서드 구현 (increaseFromReceiving 기반) - WorkOrderService: 완료 시 sales_order_id 유무에 따라 출하/재고입고 분기 - stockInFromProduction(): 품목별 양품 재고 입고 처리 - shouldStockIn(): items.options 기반 입고 대상 판단 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ class StockLot extends Model
|
|||||||
'location',
|
'location',
|
||||||
'status',
|
'status',
|
||||||
'receiving_id',
|
'receiving_id',
|
||||||
|
'work_order_id',
|
||||||
'created_by',
|
'created_by',
|
||||||
'updated_by',
|
'updated_by',
|
||||||
'deleted_by',
|
'deleted_by',
|
||||||
@@ -41,6 +42,7 @@ class StockLot extends Model
|
|||||||
'available_qty' => 'decimal:3',
|
'available_qty' => 'decimal:3',
|
||||||
'stock_id' => 'integer',
|
'stock_id' => 'integer',
|
||||||
'receiving_id' => 'integer',
|
'receiving_id' => 'integer',
|
||||||
|
'work_order_id' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +70,14 @@ public function receiving(): BelongsTo
|
|||||||
return $this->belongsTo(Receiving::class);
|
return $this->belongsTo(Receiving::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업지시 관계 (생산입고)
|
||||||
|
*/
|
||||||
|
public function workOrder(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\Production\WorkOrder::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 생성자 관계
|
* 생성자 관계
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -48,12 +48,15 @@ class StockTransaction extends Model
|
|||||||
|
|
||||||
public const REASON_ORDER_CANCEL = 'order_cancel';
|
public const REASON_ORDER_CANCEL = 'order_cancel';
|
||||||
|
|
||||||
|
public const REASON_PRODUCTION_OUTPUT = 'production_output';
|
||||||
|
|
||||||
public const REASONS = [
|
public const REASONS = [
|
||||||
self::REASON_RECEIVING => '입고',
|
self::REASON_RECEIVING => '입고',
|
||||||
self::REASON_WORK_ORDER_INPUT => '생산투입',
|
self::REASON_WORK_ORDER_INPUT => '생산투입',
|
||||||
self::REASON_SHIPMENT => '출하',
|
self::REASON_SHIPMENT => '출하',
|
||||||
self::REASON_ORDER_CONFIRM => '수주확정',
|
self::REASON_ORDER_CONFIRM => '수주확정',
|
||||||
self::REASON_ORDER_CANCEL => '수주취소',
|
self::REASON_ORDER_CANCEL => '수주취소',
|
||||||
|
self::REASON_PRODUCTION_OUTPUT => '생산입고',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -111,4 +114,4 @@ public function getReasonLabelAttribute(): string
|
|||||||
{
|
{
|
||||||
return self::REASONS[$this->reason] ?? ($this->reason ?? '-');
|
return self::REASONS[$this->reason] ?? ($this->reason ?? '-');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Items\Item;
|
use App\Models\Items\Item;
|
||||||
|
use App\Models\Production\WorkOrder;
|
||||||
|
use App\Models\Production\WorkOrderItem;
|
||||||
use App\Models\Tenants\Receiving;
|
use App\Models\Tenants\Receiving;
|
||||||
use App\Models\Tenants\Stock;
|
use App\Models\Tenants\Stock;
|
||||||
use App\Models\Tenants\StockLot;
|
use App\Models\Tenants\StockLot;
|
||||||
@@ -313,6 +315,99 @@ public function increaseFromReceiving(Receiving $receiving): StockLot
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생산 완료 시 완성품 재고 입고
|
||||||
|
*
|
||||||
|
* increaseFromReceiving()을 기반으로 구현.
|
||||||
|
* 선생산(수주 없는 작업지시) 완료 시 양품을 재고로 적재.
|
||||||
|
*
|
||||||
|
* @param WorkOrder $workOrder 선생산 작업지시
|
||||||
|
* @param WorkOrderItem $woItem 작업지시 품목
|
||||||
|
* @param float $goodQty 양품 수량
|
||||||
|
* @param string $lotNo LOT 번호
|
||||||
|
* @return StockLot 생성된 StockLot
|
||||||
|
*/
|
||||||
|
public function increaseFromProduction(
|
||||||
|
WorkOrder $workOrder,
|
||||||
|
WorkOrderItem $woItem,
|
||||||
|
float $goodQty,
|
||||||
|
string $lotNo
|
||||||
|
): StockLot {
|
||||||
|
if (! $woItem->item_id) {
|
||||||
|
throw new \Exception(__('error.stock.item_id_required'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = $this->tenantId();
|
||||||
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($workOrder, $woItem, $goodQty, $lotNo, $tenantId, $userId) {
|
||||||
|
// 1. Stock 조회 또는 생성
|
||||||
|
$stock = $this->getOrCreateStock($woItem->item_id);
|
||||||
|
|
||||||
|
// 2. FIFO 순서 계산
|
||||||
|
$fifoOrder = $this->getNextFifoOrder($stock->id);
|
||||||
|
|
||||||
|
// 3. StockLot 생성
|
||||||
|
$stockLot = new StockLot;
|
||||||
|
$stockLot->tenant_id = $tenantId;
|
||||||
|
$stockLot->stock_id = $stock->id;
|
||||||
|
$stockLot->lot_no = $lotNo;
|
||||||
|
$stockLot->fifo_order = $fifoOrder;
|
||||||
|
$stockLot->receipt_date = now()->toDateString();
|
||||||
|
$stockLot->qty = $goodQty;
|
||||||
|
$stockLot->reserved_qty = 0;
|
||||||
|
$stockLot->available_qty = $goodQty;
|
||||||
|
$stockLot->unit = $woItem->unit ?? 'EA';
|
||||||
|
$stockLot->supplier = null;
|
||||||
|
$stockLot->supplier_lot = null;
|
||||||
|
$stockLot->po_number = null;
|
||||||
|
$stockLot->location = null;
|
||||||
|
$stockLot->status = 'available';
|
||||||
|
$stockLot->receiving_id = null;
|
||||||
|
$stockLot->work_order_id = $workOrder->id;
|
||||||
|
$stockLot->created_by = $userId;
|
||||||
|
$stockLot->updated_by = $userId;
|
||||||
|
$stockLot->save();
|
||||||
|
|
||||||
|
// 4. Stock 합계 갱신
|
||||||
|
$stock->refreshFromLots();
|
||||||
|
|
||||||
|
// 5. 거래 이력 기록
|
||||||
|
$this->recordTransaction(
|
||||||
|
stock: $stock,
|
||||||
|
type: StockTransaction::TYPE_IN,
|
||||||
|
qty: $goodQty,
|
||||||
|
reason: StockTransaction::REASON_PRODUCTION_OUTPUT,
|
||||||
|
referenceType: 'work_order',
|
||||||
|
referenceId: $workOrder->id,
|
||||||
|
lotNo: $lotNo,
|
||||||
|
stockLotId: $stockLot->id
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 감사 로그 기록
|
||||||
|
$this->logStockChange(
|
||||||
|
stock: $stock,
|
||||||
|
action: 'production_in',
|
||||||
|
reason: 'production_output',
|
||||||
|
referenceType: 'work_order',
|
||||||
|
referenceId: $workOrder->id,
|
||||||
|
qtyChange: $goodQty,
|
||||||
|
lotNo: $lotNo
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::info('Stock increased from production', [
|
||||||
|
'work_order_id' => $workOrder->id,
|
||||||
|
'item_id' => $woItem->item_id,
|
||||||
|
'stock_id' => $stock->id,
|
||||||
|
'stock_lot_id' => $stockLot->id,
|
||||||
|
'qty' => $goodQty,
|
||||||
|
'lot_no' => $lotNo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $stockLot;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입고 수정 시 재고 조정 (차이만큼 증감)
|
* 입고 수정 시 재고 조정 (차이만큼 증감)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ class WorkOrderService extends Service
|
|||||||
private const AUDIT_TARGET = 'work_order';
|
private const AUDIT_TARGET = 'work_order';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AuditLogger $auditLogger
|
private readonly AuditLogger $auditLogger,
|
||||||
|
private readonly StockService $stockService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -587,15 +588,62 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
|
|||||||
// 연결된 수주(Order) 상태 동기화
|
// 연결된 수주(Order) 상태 동기화
|
||||||
$this->syncOrderStatus($workOrder, $tenantId);
|
$this->syncOrderStatus($workOrder, $tenantId);
|
||||||
|
|
||||||
// 작업완료 시 자동 출하 생성
|
// 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고
|
||||||
if ($status === WorkOrder::STATUS_COMPLETED) {
|
if ($status === WorkOrder::STATUS_COMPLETED) {
|
||||||
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
|
if ($workOrder->sales_order_id) {
|
||||||
|
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
|
||||||
|
} else {
|
||||||
|
$this->stockInFromProduction($workOrder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선생산 작업지시 완료 시 완성품을 재고로 입고
|
||||||
|
*
|
||||||
|
* 수주 없는 작업지시(sales_order_id = null)가 완료되면
|
||||||
|
* 각 품목의 양품 수량을 재고 시스템에 입고 처리합니다.
|
||||||
|
*/
|
||||||
|
private function stockInFromProduction(WorkOrder $workOrder): void
|
||||||
|
{
|
||||||
|
$workOrder->loadMissing('items.item');
|
||||||
|
|
||||||
|
foreach ($workOrder->items as $woItem) {
|
||||||
|
if ($this->shouldStockIn($woItem)) {
|
||||||
|
$resultData = $woItem->options['result'] ?? [];
|
||||||
|
$goodQty = (float) ($resultData['good_qty'] ?? $woItem->quantity);
|
||||||
|
$lotNo = $resultData['lot_no'] ?? '';
|
||||||
|
|
||||||
|
if ($goodQty > 0 && $lotNo) {
|
||||||
|
$this->stockService->increaseFromProduction(
|
||||||
|
$workOrder, $woItem, $goodQty, $lotNo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목이 생산입고 대상인지 판단
|
||||||
|
*
|
||||||
|
* items.options의 production_source와 lot_managed 속성으로 판단.
|
||||||
|
*/
|
||||||
|
private function shouldStockIn(WorkOrderItem $woItem): bool
|
||||||
|
{
|
||||||
|
$item = $woItem->item;
|
||||||
|
if (! $item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = $item->options ?? [];
|
||||||
|
|
||||||
|
return ($options['production_source'] ?? null) === 'self_produced'
|
||||||
|
&& ($options['lot_managed'] ?? false) === true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업지시 완료 시 자동 출하 생성
|
* 작업지시 완료 시 자동 출하 생성
|
||||||
*
|
*
|
||||||
@@ -2193,8 +2241,8 @@ public function getInspectionReport(int $workOrderId): array
|
|||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
|
|
||||||
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
||||||
->with(['order', 'items' => function ($q) {
|
->with(['salesOrder', 'items' => function ($q) {
|
||||||
$q->ordered();
|
$q->ordered()->with('sourceOrderItem');
|
||||||
}])
|
}])
|
||||||
->find($workOrderId);
|
->find($workOrderId);
|
||||||
|
|
||||||
@@ -2202,18 +2250,61 @@ public function getInspectionReport(int $workOrderId): array
|
|||||||
throw new NotFoundHttpException(__('error.not_found'));
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = $workOrder->items->map(function ($item) {
|
// 개소(order_node_id)별 그룹핑 — WorkerScreen과 동일한 구조
|
||||||
return [
|
$grouped = $workOrder->items->groupBy(
|
||||||
'id' => $item->id,
|
fn ($item) => $item->sourceOrderItem?->order_node_id ?? 'unassigned'
|
||||||
'item_name' => $item->item_name,
|
);
|
||||||
'specification' => $item->specification,
|
|
||||||
'quantity' => $item->quantity,
|
$nodeIds = $grouped->keys()->filter(fn ($k) => $k !== 'unassigned')->values()->all();
|
||||||
'sort_order' => $item->sort_order,
|
$nodes = ! empty($nodeIds)
|
||||||
'status' => $item->status,
|
? \App\Models\Orders\OrderNode::whereIn('id', $nodeIds)->get()->keyBy('id')
|
||||||
'options' => $item->options,
|
: collect();
|
||||||
'inspection_data' => $item->getInspectionData(),
|
|
||||||
|
$nodeGroups = [];
|
||||||
|
foreach ($grouped as $nodeId => $groupItems) {
|
||||||
|
$node = $nodeId !== 'unassigned' ? $nodes->get($nodeId) : null;
|
||||||
|
$nodeOpts = $node?->options ?? [];
|
||||||
|
|
||||||
|
$firstItem = $groupItems->first();
|
||||||
|
$soi = $firstItem->sourceOrderItem;
|
||||||
|
$floorCode = $soi?->floor_code ?? '-';
|
||||||
|
$symbolCode = $soi?->symbol_code ?? '-';
|
||||||
|
$floorLabel = collect([$floorCode, $symbolCode])
|
||||||
|
->filter(fn ($v) => $v && $v !== '-')->join('/');
|
||||||
|
|
||||||
|
$nodeGroups[] = [
|
||||||
|
'node_id' => $nodeId !== 'unassigned' ? (int) $nodeId : null,
|
||||||
|
'node_name' => $floorLabel ?: ($node?->name ?? '미지정'),
|
||||||
|
'floor' => $nodeOpts['floor'] ?? $floorCode,
|
||||||
|
'code' => $nodeOpts['symbol'] ?? $symbolCode,
|
||||||
|
'width' => $nodeOpts['width'] ?? 0,
|
||||||
|
'height' => $nodeOpts['height'] ?? 0,
|
||||||
|
'total_quantity' => $groupItems->sum('quantity'),
|
||||||
|
'options' => $nodeOpts,
|
||||||
|
'items' => $groupItems->map(fn ($item) => [
|
||||||
|
'id' => $item->id,
|
||||||
|
'item_name' => $item->item_name,
|
||||||
|
'specification' => $item->specification,
|
||||||
|
'quantity' => $item->quantity,
|
||||||
|
'sort_order' => $item->sort_order,
|
||||||
|
'status' => $item->status,
|
||||||
|
'options' => $item->options,
|
||||||
|
'inspection_data' => $item->getInspectionData(),
|
||||||
|
])->values()->all(),
|
||||||
];
|
];
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// 플랫 아이템 목록 (summary 계산용)
|
||||||
|
$items = $workOrder->items->map(fn ($item) => [
|
||||||
|
'id' => $item->id,
|
||||||
|
'item_name' => $item->item_name,
|
||||||
|
'specification' => $item->specification,
|
||||||
|
'quantity' => $item->quantity,
|
||||||
|
'sort_order' => $item->sort_order,
|
||||||
|
'status' => $item->status,
|
||||||
|
'options' => $item->options,
|
||||||
|
'inspection_data' => $item->getInspectionData(),
|
||||||
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'work_order' => [
|
'work_order' => [
|
||||||
@@ -2223,13 +2314,14 @@ public function getInspectionReport(int $workOrderId): array
|
|||||||
'planned_date' => $workOrder->planned_date,
|
'planned_date' => $workOrder->planned_date,
|
||||||
'due_date' => $workOrder->due_date,
|
'due_date' => $workOrder->due_date,
|
||||||
],
|
],
|
||||||
'order' => $workOrder->order ? [
|
'order' => $workOrder->salesOrder ? [
|
||||||
'id' => $workOrder->order->id,
|
'id' => $workOrder->salesOrder->id,
|
||||||
'order_no' => $workOrder->order->order_no,
|
'order_no' => $workOrder->salesOrder->order_no,
|
||||||
'client_name' => $workOrder->order->client_name ?? null,
|
'client_name' => $workOrder->salesOrder->client_name ?? null,
|
||||||
'site_name' => $workOrder->order->site_name ?? null,
|
'site_name' => $workOrder->salesOrder->site_name ?? null,
|
||||||
'order_date' => $workOrder->order->order_date ?? null,
|
'order_date' => $workOrder->salesOrder->order_date ?? null,
|
||||||
] : null,
|
] : null,
|
||||||
|
'node_groups' => $nodeGroups,
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'total_items' => $items->count(),
|
'total_items' => $items->count(),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 선생산 완료 시 재고 입고를 위해 stock_lots에 work_order_id FK 추가
|
||||||
|
* - 구매입고: receiving_id 참조
|
||||||
|
* - 생산입고: work_order_id 참조
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('stock_lots', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('work_order_id')
|
||||||
|
->nullable()
|
||||||
|
->after('receiving_id')
|
||||||
|
->comment('생산입고 시 작업지시 참조');
|
||||||
|
|
||||||
|
$table->foreign('work_order_id')
|
||||||
|
->references('id')
|
||||||
|
->on('work_orders')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('stock_lots', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['work_order_id']);
|
||||||
|
$table->dropColumn('work_order_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user