feat(재고): stock_transactions 입출고 거래 이력 테이블 추가
- stock_transactions 마이그레이션 생성 (type, qty, balance_qty, reference) - StockTransaction 모델 (IN/OUT/RESERVE/RELEASE 타입, 사유 상수) - StockService 5개 메서드에 거래 이력 기록 추가 - increaseFromReceiving → IN - decreaseFIFO → OUT (LOT별) - reserve → RESERVE (LOT별) - releaseReservation → RELEASE (LOT별) - decreaseForShipment → OUT (LOT별) - Stock 모델에 transactions() 관계 추가 - 기존 audit_logs 기록은 유지 (감사 로그와 거래 이력 목적 분리) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,14 @@ public function lots(): HasMany
|
||||
return $this->hasMany(StockLot::class)->orderBy('fifo_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 이력 관계
|
||||
*/
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockTransaction::class)->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자 관계
|
||||
*/
|
||||
|
||||
114
app/Models/Tenants/StockTransaction.php
Normal file
114
app/Models/Tenants/StockTransaction.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 재고 입출고 거래 이력
|
||||
*
|
||||
* 모든 재고 변동을 스택으로 기록합니다.
|
||||
* - IN: 입고 (재고 증가)
|
||||
* - OUT: 출고/생산투입 (재고 감소)
|
||||
* - RESERVE: 수주 확정으로 예약
|
||||
* - RELEASE: 수주 취소로 예약 해제
|
||||
*/
|
||||
class StockTransaction extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
const UPDATED_AT = null; // updated_at 사용 안함 (이력은 불변)
|
||||
|
||||
// 거래 유형 상수
|
||||
public const TYPE_IN = 'IN';
|
||||
|
||||
public const TYPE_OUT = 'OUT';
|
||||
|
||||
public const TYPE_RESERVE = 'RESERVE';
|
||||
|
||||
public const TYPE_RELEASE = 'RELEASE';
|
||||
|
||||
public const TYPES = [
|
||||
self::TYPE_IN => '입고',
|
||||
self::TYPE_OUT => '출고',
|
||||
self::TYPE_RESERVE => '예약',
|
||||
self::TYPE_RELEASE => '예약해제',
|
||||
];
|
||||
|
||||
// 사유 상수
|
||||
public const REASON_RECEIVING = 'receiving';
|
||||
|
||||
public const REASON_WORK_ORDER_INPUT = 'work_order_input';
|
||||
|
||||
public const REASON_SHIPMENT = 'shipment';
|
||||
|
||||
public const REASON_ORDER_CONFIRM = 'order_confirm';
|
||||
|
||||
public const REASON_ORDER_CANCEL = 'order_cancel';
|
||||
|
||||
public const REASONS = [
|
||||
self::REASON_RECEIVING => '입고',
|
||||
self::REASON_WORK_ORDER_INPUT => '생산투입',
|
||||
self::REASON_SHIPMENT => '출하',
|
||||
self::REASON_ORDER_CONFIRM => '수주확정',
|
||||
self::REASON_ORDER_CANCEL => '수주취소',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'stock_id',
|
||||
'stock_lot_id',
|
||||
'type',
|
||||
'qty',
|
||||
'balance_qty',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
'lot_no',
|
||||
'reason',
|
||||
'remark',
|
||||
'item_code',
|
||||
'item_name',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'stock_id' => 'integer',
|
||||
'stock_lot_id' => 'integer',
|
||||
'qty' => 'decimal:3',
|
||||
'balance_qty' => 'decimal:3',
|
||||
'reference_id' => 'integer',
|
||||
'created_by' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ===== 관계 =====
|
||||
|
||||
public function stock(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Stock::class);
|
||||
}
|
||||
|
||||
public function stockLot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockLot::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Members\User::class, 'created_by');
|
||||
}
|
||||
|
||||
// ===== Accessors =====
|
||||
|
||||
public function getTypeLabelAttribute(): string
|
||||
{
|
||||
return self::TYPES[$this->type] ?? $this->type;
|
||||
}
|
||||
|
||||
public function getReasonLabelAttribute(): string
|
||||
{
|
||||
return self::REASONS[$this->reason] ?? ($this->reason ?? '-');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
use App\Models\Tenants\Receiving;
|
||||
use App\Models\Tenants\Stock;
|
||||
use App\Models\Tenants\StockLot;
|
||||
use App\Models\Tenants\StockTransaction;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -277,7 +278,19 @@ public function increaseFromReceiving(Receiving $receiving): StockLot
|
||||
// 4. Stock 정보 갱신 (LOT 기반)
|
||||
$stock->refreshFromLots();
|
||||
|
||||
// 5. 감사 로그 기록
|
||||
// 5. 거래 이력 기록
|
||||
$this->recordTransaction(
|
||||
stock: $stock,
|
||||
type: StockTransaction::TYPE_IN,
|
||||
qty: $receiving->receiving_qty,
|
||||
reason: StockTransaction::REASON_RECEIVING,
|
||||
referenceType: 'receiving',
|
||||
referenceId: $receiving->id,
|
||||
lotNo: $receiving->lot_no,
|
||||
stockLotId: $stockLot->id
|
||||
);
|
||||
|
||||
// 6. 감사 로그 기록
|
||||
$this->logStockChange(
|
||||
stock: $stock,
|
||||
action: 'stock_increase',
|
||||
@@ -448,7 +461,21 @@ public function decreaseFIFO(int $itemId, float $qty, string $reason, int $refer
|
||||
$stock->last_issue_date = now();
|
||||
$stock->save();
|
||||
|
||||
// 6. 감사 로그 기록
|
||||
// 6. 거래 이력 기록 (LOT별)
|
||||
foreach ($deductedLots as $dl) {
|
||||
$this->recordTransaction(
|
||||
stock: $stock,
|
||||
type: StockTransaction::TYPE_OUT,
|
||||
qty: -$dl['deducted_qty'],
|
||||
reason: $reason,
|
||||
referenceType: $reason,
|
||||
referenceId: $referenceId,
|
||||
lotNo: $dl['lot_no'],
|
||||
stockLotId: $dl['lot_id']
|
||||
);
|
||||
}
|
||||
|
||||
// 7. 감사 로그 기록
|
||||
$this->logStockChange(
|
||||
stock: $stock,
|
||||
action: 'stock_decrease',
|
||||
@@ -591,7 +618,21 @@ public function reserve(int $itemId, float $qty, int $orderId): void
|
||||
// 4. Stock 정보 갱신
|
||||
$stock->refreshFromLots();
|
||||
|
||||
// 5. 감사 로그 기록
|
||||
// 5. 거래 이력 기록 (LOT별)
|
||||
foreach ($reservedLots as $rl) {
|
||||
$this->recordTransaction(
|
||||
stock: $stock,
|
||||
type: StockTransaction::TYPE_RESERVE,
|
||||
qty: $rl['reserved_qty'],
|
||||
reason: StockTransaction::REASON_ORDER_CONFIRM,
|
||||
referenceType: 'order',
|
||||
referenceId: $orderId,
|
||||
lotNo: $rl['lot_no'],
|
||||
stockLotId: $rl['lot_id']
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 감사 로그 기록
|
||||
$this->logStockChange(
|
||||
stock: $stock,
|
||||
action: 'stock_reserve',
|
||||
@@ -680,7 +721,21 @@ public function releaseReservation(int $itemId, float $qty, int $orderId): void
|
||||
// 3. Stock 정보 갱신
|
||||
$stock->refreshFromLots();
|
||||
|
||||
// 4. 감사 로그 기록
|
||||
// 4. 거래 이력 기록 (LOT별)
|
||||
foreach ($releasedLots as $rl) {
|
||||
$this->recordTransaction(
|
||||
stock: $stock,
|
||||
type: StockTransaction::TYPE_RELEASE,
|
||||
qty: -$rl['released_qty'],
|
||||
reason: StockTransaction::REASON_ORDER_CANCEL,
|
||||
referenceType: 'order',
|
||||
referenceId: $orderId,
|
||||
lotNo: $rl['lot_no'],
|
||||
stockLotId: $rl['lot_id']
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 감사 로그 기록
|
||||
$this->logStockChange(
|
||||
stock: $stock,
|
||||
action: 'stock_release',
|
||||
@@ -839,7 +894,21 @@ public function decreaseForShipment(int $itemId, float $qty, int $shipmentId, ?i
|
||||
$stock->last_issue_date = now();
|
||||
$stock->save();
|
||||
|
||||
// 5. 감사 로그 기록
|
||||
// 5. 거래 이력 기록 (LOT별)
|
||||
foreach ($deductedLots as $dl) {
|
||||
$this->recordTransaction(
|
||||
stock: $stock,
|
||||
type: StockTransaction::TYPE_OUT,
|
||||
qty: -$dl['deducted_qty'],
|
||||
reason: StockTransaction::REASON_SHIPMENT,
|
||||
referenceType: 'shipment',
|
||||
referenceId: $shipmentId,
|
||||
lotNo: $dl['lot_no'],
|
||||
stockLotId: $dl['lot_id']
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 감사 로그 기록
|
||||
$this->logStockChange(
|
||||
stock: $stock,
|
||||
action: 'stock_decrease',
|
||||
@@ -861,6 +930,58 @@ public function decreaseForShipment(int $itemId, float $qty, int $shipmentId, ?i
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 거래 이력 기록
|
||||
*
|
||||
* @param Stock $stock 재고
|
||||
* @param string $type 거래유형 (IN, OUT, RESERVE, RELEASE)
|
||||
* @param float $qty 변동 수량 (입고: 양수, 출고: 음수)
|
||||
* @param string $reason 사유
|
||||
* @param string $referenceType 참조 유형
|
||||
* @param int $referenceId 참조 ID
|
||||
* @param string|null $lotNo LOT번호
|
||||
* @param int|null $stockLotId StockLot ID
|
||||
* @param string|null $remark 비고
|
||||
*/
|
||||
private function recordTransaction(
|
||||
Stock $stock,
|
||||
string $type,
|
||||
float $qty,
|
||||
string $reason,
|
||||
string $referenceType,
|
||||
int $referenceId,
|
||||
?string $lotNo = null,
|
||||
?int $stockLotId = null,
|
||||
?string $remark = null
|
||||
): void {
|
||||
try {
|
||||
StockTransaction::create([
|
||||
'tenant_id' => $stock->tenant_id,
|
||||
'stock_id' => $stock->id,
|
||||
'stock_lot_id' => $stockLotId,
|
||||
'type' => $type,
|
||||
'qty' => $qty,
|
||||
'balance_qty' => (float) $stock->stock_qty,
|
||||
'reference_type' => $referenceType,
|
||||
'reference_id' => $referenceId,
|
||||
'lot_no' => $lotNo,
|
||||
'reason' => $reason,
|
||||
'remark' => $remark,
|
||||
'item_code' => $stock->item_code,
|
||||
'item_name' => $stock->item_name,
|
||||
'created_by' => $this->apiUserId(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// 거래 이력 기록 실패는 비즈니스 로직에 영향을 주지 않음
|
||||
Log::warning('Failed to record stock transaction', [
|
||||
'stock_id' => $stock->id,
|
||||
'type' => $type,
|
||||
'qty' => $qty,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 변경 감사 로그 기록
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 재고 입출고 거래 이력 테이블
|
||||
*
|
||||
* 모든 재고 변동을 스택으로 쌓아 이력 관리합니다.
|
||||
* audit_logs와 별개로 재고 전용 거래 이력을 제공합니다.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('stock_transactions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
|
||||
// 재고 참조
|
||||
$table->unsignedBigInteger('stock_id')->comment('재고 ID');
|
||||
$table->unsignedBigInteger('stock_lot_id')->nullable()->comment('LOT ID (입고/출고 시)');
|
||||
|
||||
// 거래 유형
|
||||
$table->string('type', 20)->comment('거래유형: IN(입고), OUT(출고), RESERVE(예약), RELEASE(예약해제)');
|
||||
|
||||
// 수량
|
||||
$table->decimal('qty', 15, 3)->comment('변동 수량 (양수: 증가, 음수: 감소)');
|
||||
$table->decimal('balance_qty', 15, 3)->comment('거래 후 재고 잔량');
|
||||
|
||||
// 참조 정보 (다형성)
|
||||
$table->string('reference_type', 50)->nullable()->comment('참조 유형: receiving, work_order, shipment, order');
|
||||
$table->unsignedBigInteger('reference_id')->nullable()->comment('참조 ID');
|
||||
|
||||
// 상세 정보
|
||||
$table->string('lot_no', 50)->nullable()->comment('LOT번호');
|
||||
$table->string('reason', 50)->nullable()->comment('사유: receiving, work_order_input, shipment, order_confirm, order_cancel');
|
||||
$table->string('remark', 500)->nullable()->comment('비고');
|
||||
|
||||
// 품목 스냅샷 (조회 성능용)
|
||||
$table->string('item_code', 50)->comment('품목코드');
|
||||
$table->string('item_name', 200)->comment('품목명');
|
||||
|
||||
// 감사 정보
|
||||
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
|
||||
$table->timestamp('created_at')->useCurrent()->comment('생성일시');
|
||||
|
||||
// 인덱스
|
||||
$table->index('tenant_id');
|
||||
$table->index('stock_id');
|
||||
$table->index('stock_lot_id');
|
||||
$table->index('type');
|
||||
$table->index('reference_type');
|
||||
$table->index(['stock_id', 'created_at']);
|
||||
$table->index(['tenant_id', 'item_code']);
|
||||
$table->index(['tenant_id', 'type', 'created_at']);
|
||||
$table->index(['reference_type', 'reference_id']);
|
||||
|
||||
// 외래키
|
||||
$table->foreign('stock_id')->references('id')->on('stocks')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('stock_transactions');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user