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;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Traits\BelongsToTenant;
|
use App\Traits\BelongsToTenant;
|
||||||
use App\Models\Traits\ModelTrait;
|
use App\Traits\ModelTrait;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class Stock extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'tenant_id',
|
'tenant_id',
|
||||||
|
'item_id',
|
||||||
'item_code',
|
'item_code',
|
||||||
'item_name',
|
'item_name',
|
||||||
'item_type',
|
'item_type',
|
||||||
@@ -65,6 +66,14 @@ class Stock extends Model
|
|||||||
'out' => '없음',
|
'out' => '없음',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목 관계
|
||||||
|
*/
|
||||||
|
public function item(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\Items\Item::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LOT 관계
|
* LOT 관계
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ public function index(array $params): LengthAwarePaginator
|
|||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
|
|
||||||
$query = Stock::query()
|
$query = Stock::query()
|
||||||
->where('tenant_id', $tenantId);
|
->where('tenant_id', $tenantId)
|
||||||
|
->with('item');
|
||||||
|
|
||||||
// 검색어 필터
|
// 검색어 필터
|
||||||
if (! empty($params['search'])) {
|
if (! empty($params['search'])) {
|
||||||
@@ -90,7 +91,7 @@ public function show(int $id): Stock
|
|||||||
|
|
||||||
return Stock::query()
|
return Stock::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->with(['lots' => function ($query) {
|
->with(['item', 'lots' => function ($query) {
|
||||||
$query->orderBy('fifo_order');
|
$query->orderBy('fifo_order');
|
||||||
}])
|
}])
|
||||||
->findOrFail($id);
|
->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