feat(SAM/API): Stock 모델에 Item 관계 추가 및 시더 생성

- Stock 모델에 item_id 필드 및 item() 관계 추가
- StockService에 item eager loading 적용
- stocks 테이블 item_id 마이그레이션 추가
- StockReceivingSeeder 더미 데이터 시더 생성
- Process 모델 Traits 경로 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-29 15:31:00 +09:00
parent 56b9805c24
commit a639e0fa60
5 changed files with 274 additions and 4 deletions

View File

@@ -2,8 +2,8 @@
namespace App\Models;
use App\Models\Traits\BelongsToTenant;
use App\Models\Traits\ModelTrait;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

View File

@@ -14,6 +14,7 @@ class Stock extends Model
protected $fillable = [
'tenant_id',
'item_id',
'item_code',
'item_name',
'item_type',
@@ -65,6 +66,14 @@ class Stock extends Model
'out' => '없음',
];
/**
* 품목 관계
*/
public function item(): BelongsTo
{
return $this->belongsTo(\App\Models\Items\Item::class);
}
/**
* LOT 관계
*/

View File

@@ -15,7 +15,8 @@ public function index(array $params): LengthAwarePaginator
$tenantId = $this->tenantId();
$query = Stock::query()
->where('tenant_id', $tenantId);
->where('tenant_id', $tenantId)
->with('item');
// 검색어 필터
if (! empty($params['search'])) {
@@ -90,7 +91,7 @@ public function show(int $id): Stock
return Stock::query()
->where('tenant_id', $tenantId)
->with(['lots' => function ($query) {
->with(['item', 'lots' => function ($query) {
$query->orderBy('fifo_order');
}])
->findOrFail($id);

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('stocks', function (Blueprint $table) {
$table->foreignId('item_id')->nullable()->after('tenant_id')
->comment('품목 ID (items.id 참조)');
$table->index('item_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('stocks', function (Blueprint $table) {
$table->dropIndex(['item_id']);
$table->dropColumn('item_id');
});
}
};

View File

@@ -0,0 +1,229 @@
<?php
namespace Database\Seeders;
use App\Models\Items\Item;
use App\Models\Tenants\Receiving;
use App\Models\Tenants\Stock;
use App\Models\Tenants\StockLot;
use Illuminate\Database\Seeder;
/**
* 재고현황, 입고관리 테스트 데이터 Seeder
*
* Tenant 287 기준으로 Items 데이터를 기반으로 Stock, Receiving 생성
*/
class StockReceivingSeeder extends Seeder
{
private const TENANT_ID = 287;
private const USER_ID = 33;
/**
* Item.item_type → Stock.item_type 매핑
*/
private const ITEM_TYPE_MAP = [
'FG' => 'purchased_part', // 완제품
'PT' => 'bent_part', // 부품
'SM' => 'sub_material', // 부자재
'RM' => 'raw_material', // 원자재
'CS' => 'consumable', // 소모품
];
public function run(): void
{
$this->command->info('Stock & Receiving Seeder 시작 (Tenant: '.self::TENANT_ID.')');
// Tenant 287의 품목 조회
$items = Item::withoutGlobalScopes()
->where('tenant_id', self::TENANT_ID)
->get();
if ($items->isEmpty()) {
$this->command->warn('Tenant '.self::TENANT_ID.'에 품목이 없습니다.');
return;
}
$this->command->info('품목 수: '.$items->count());
// 각 품목에 대해 재고 및 입고 데이터 생성
foreach ($items as $item) {
$this->createStockData($item);
$this->createReceivingData($item);
}
$this->command->info('Stock & Receiving Seeder 완료');
}
/**
* 재고 데이터 생성
*/
private function createStockData(Item $item): void
{
$stockType = self::ITEM_TYPE_MAP[$item->item_type] ?? 'raw_material';
// 랜덤 재고 수량
$stockQty = rand(50, 500);
$safetyStock = rand(10, 50);
$reservedQty = rand(0, min(20, $stockQty));
$availableQty = $stockQty - $reservedQty;
// 재고 상태 결정
$status = 'normal';
if ($stockQty <= 0) {
$status = 'out';
} elseif ($stockQty < $safetyStock) {
$status = 'low';
}
// 재고 레코드 생성
$stock = Stock::withoutGlobalScopes()->create([
'tenant_id' => self::TENANT_ID,
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'item_type' => $stockType,
'specification' => $item->attributes['specification'] ?? null,
'unit' => $item->unit,
'stock_qty' => $stockQty,
'safety_stock' => $safetyStock,
'reserved_qty' => $reservedQty,
'available_qty' => $availableQty,
'lot_count' => rand(1, 3),
'oldest_lot_date' => now()->subDays(rand(30, 180)),
'location' => $this->randomLocation(),
'status' => $status,
'last_receipt_date' => now()->subDays(rand(1, 30)),
'last_issue_date' => now()->subDays(rand(1, 14)),
'created_by' => self::USER_ID,
]);
// LOT 데이터 생성 (1~3개)
$lotCount = rand(1, 3);
$remainingQty = $stockQty;
for ($i = 0; $i < $lotCount && $remainingQty > 0; $i++) {
$lotQty = $i === $lotCount - 1 ? $remainingQty : rand(10, $remainingQty);
$remainingQty -= $lotQty;
StockLot::withoutGlobalScopes()->create([
'tenant_id' => self::TENANT_ID,
'stock_id' => $stock->id,
'lot_no' => 'LOT'.date('Ymd').'-'.str_pad($i + 1, 3, '0', STR_PAD_LEFT),
'supplier_lot' => 'SUP-'.rand(1000, 9999),
'qty' => $lotQty,
'reserved_qty' => 0,
'available_qty' => $lotQty,
'receipt_date' => now()->subDays(rand(1, 60)),
'fifo_order' => $i + 1,
'location' => $stock->location,
'status' => 'available',
'created_by' => self::USER_ID,
]);
}
$this->command->info(" - Stock 생성: {$item->code} ({$stockQty} {$item->unit})");
}
/**
* 입고 데이터 생성
*/
private function createReceivingData(Item $item): void
{
// 각 품목당 2~4건의 입고 데이터 생성
$count = rand(2, 4);
for ($i = 0; $i < $count; $i++) {
$orderQty = rand(50, 200);
$status = $this->randomReceivingStatus();
$receivingQty = $status === 'completed' ? $orderQty : ($status === 'receiving_pending' ? rand(1, $orderQty) : null);
// 고유번호 생성을 위해 uniqid 사용
$uniqueId = substr(uniqid(), -6);
Receiving::withoutGlobalScopes()->create([
'tenant_id' => self::TENANT_ID,
'receiving_number' => 'RCV-'.date('Ymd').'-'.$uniqueId,
'order_no' => 'PO-'.date('Ymd').'-'.str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT),
'order_date' => now()->subDays(rand(10, 60)),
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'specification' => $item->attributes['specification'] ?? null,
'supplier' => $this->randomSupplier(),
'order_qty' => $orderQty,
'order_unit' => $item->unit,
'due_date' => now()->addDays(rand(-5, 14)),
'receiving_qty' => $receivingQty,
'receiving_date' => $status === 'completed' ? now()->subDays(rand(1, 10)) : null,
'lot_no' => $status === 'completed' ? 'LOT'.date('Ymd').'-'.str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT) : null,
'supplier_lot' => $status === 'completed' ? 'SUP-'.rand(1000, 9999) : null,
'receiving_location' => $status === 'completed' ? $this->randomLocation() : null,
'receiving_manager' => $status === 'completed' ? '홍킬동' : null,
'status' => $status,
'remark' => $this->randomRemark(),
'created_by' => self::USER_ID,
]);
}
$this->command->info(" - Receiving 생성: {$item->code} ({$count}건)");
}
/**
* 랜덤 창고 위치
*/
private function randomLocation(): string
{
$locations = ['A-01-01', 'A-01-02', 'A-02-01', 'B-01-01', 'B-02-01', 'C-01-01'];
return $locations[array_rand($locations)];
}
/**
* 랜덤 공급업체
*/
private function randomSupplier(): string
{
$suppliers = ['(주)대한철강', '삼성부품', 'LG전자', '현대산업', '한국소재', '동아물산'];
return $suppliers[array_rand($suppliers)];
}
/**
* 랜덤 입고 상태
*/
private function randomReceivingStatus(): string
{
$statuses = ['order_completed', 'shipping', 'inspection_pending', 'receiving_pending', 'completed'];
$weights = [15, 15, 15, 15, 40]; // completed가 더 많이
$rand = rand(1, array_sum($weights));
$cumulative = 0;
foreach ($statuses as $i => $status) {
$cumulative += $weights[$i];
if ($rand <= $cumulative) {
return $status;
}
}
return 'completed';
}
/**
* 랜덤 비고
*/
private function randomRemark(): ?string
{
$remarks = [
null,
'긴급 발주',
'정기 입고',
'추가 발주',
'품질 검사 필요',
'납기 지연 주의',
];
return $remarks[array_rand($remarks)];
}
}