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:
2026-01-29 15:05:03 +09:00
parent 847717e631
commit f7ad9ae36e
4 changed files with 317 additions and 5 deletions

View File

@@ -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');
}
/**
* 생성자 관계
*/

View 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 ?? '-');
}
}

View File

@@ -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(),
]);
}
}
/**
* 재고 변경 감사 로그 기록
*/

View File

@@ -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');
}
};