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:
@@ -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;
|
||||
|
||||
@@ -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 관계
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
229
database/seeders/StockReceivingSeeder.php
Normal file
229
database/seeders/StockReceivingSeeder.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user