diff --git a/app/Models/Tenants/Stock.php b/app/Models/Tenants/Stock.php index 15ea604..0326499 100644 --- a/app/Models/Tenants/Stock.php +++ b/app/Models/Tenants/Stock.php @@ -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'); + } + /** * 생성자 관계 */ diff --git a/app/Models/Tenants/StockTransaction.php b/app/Models/Tenants/StockTransaction.php new file mode 100644 index 0000000..2c5fad5 --- /dev/null +++ b/app/Models/Tenants/StockTransaction.php @@ -0,0 +1,114 @@ + '입고', + 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 ?? '-'); + } +} \ No newline at end of file diff --git a/app/Services/StockService.php b/app/Services/StockService.php index 629482e..764a8b5 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -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(), + ]); + } + } + /** * 재고 변경 감사 로그 기록 */ diff --git a/database/migrations/2026_01_29_000001_create_stock_transactions_table.php b/database/migrations/2026_01_29_000001_create_stock_transactions_table.php new file mode 100644 index 0000000..fa11f96 --- /dev/null +++ b/database/migrations/2026_01_29_000001_create_stock_transactions_table.php @@ -0,0 +1,69 @@ +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'); + } +}; \ No newline at end of file