feat(BE): fixing fifo system recording

This commit is contained in:
ragilap
2025-12-31 19:30:04 +07:00
parent e0dd2799fc
commit fe51f33ab4
4 changed files with 451 additions and 60 deletions
@@ -194,13 +194,17 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
StockLogId: newLog.Id,
ProductWarehouseId: productWarehouse.Id,
}
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
s.Log.Errorf("Failed to create adjustment stock: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
// Adjustment INCREASE → Replenish stock (Stockable)
note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id)
replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{
_, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{
StockableKey: "ADJUSTMENT_IN",
StockableID: newLog.Id,
StockableID: adjustmentStock.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
Note: &note,
@@ -210,15 +214,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err))
}
// Update stockable tracking fields
adjustmentStock.TotalQty = replenishResult.AddedQuantity
adjustmentStock.TotalUsed = 0
} else {
// Adjustment DECREASE → Consume stock (Usable)
consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
_, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
UsableKey: "ADJUSTMENT_OUT",
UsableID: newLog.Id,
UsableID: adjustmentStock.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
AllowPending: false, // Don't allow pending for adjustment
@@ -227,16 +227,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err))
}
// Update usable tracking fields
adjustmentStock.UsageQty = consumeResult.UsageQuantity
adjustmentStock.PendingQty = consumeResult.PendingQuantity
}
// Save AdjustmentStock record
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
s.Log.Errorf("Failed to create adjustment stock: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
}
// Update ProductWarehouse quantity (for backward compatibility/reporting)
@@ -295,16 +295,17 @@ func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.
func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) {
var rows []struct {
UsageQty float64
TotalQty float64
UomName string
}
if err := tx.
Table("recording_stocks").
Select("COALESCE(recording_stocks.usage_qty, 0) AS usage_qty, LOWER(uoms.name) AS uom_name").
Select("COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0) AS total_qty, LOWER(uoms.name) AS uom_name").
Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN uoms ON uoms.id = products.uom_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN").
Where("recording_stocks.recording_id = ?", recordingID).
Scan(&rows).Error; err != nil {
return 0, err
@@ -312,16 +313,16 @@ func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID u
var total float64
for _, row := range rows {
if row.UsageQty <= 0 {
if row.TotalQty <= 0 {
continue
}
switch strings.TrimSpace(row.UomName) {
case "kilogram", "kg", "kilograms", "kilo":
total += row.UsageQty * 1000
total += row.TotalQty * 1000
case "gram", "g", "grams":
total += row.UsageQty
total += row.TotalQty
default:
total += row.UsageQty
total += row.TotalQty
}
}
return total, nil
@@ -247,11 +247,13 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
}
mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks)
stockDesired := resetStockQuantitiesForFIFO(mappedStocks, s.FifoSvc != nil)
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
s.Log.Errorf("Failed to persist stocks: %+v", err)
return err
}
applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil)
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
return err
}
@@ -324,6 +326,49 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
hasDepletionChanges := req.Depletions != nil
hasEggChanges := req.Eggs != nil
var existingStocks []entity.RecordingStock
if hasStockChanges {
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing stocks: %+v", err)
return err
}
if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) {
s.Log.WithFields(logrus.Fields{
"recording_id": recordingEntity.Id,
"existing": summarizeExistingStocks(existingStocks),
"incoming": summarizeIncomingStocks(req.Stocks),
}).Debug("recording update stock comparison")
}
if stocksMatch(existingStocks, req.Stocks) {
hasStockChanges = false
}
}
var existingDepletions []entity.RecordingDepletion
if hasDepletionChanges {
existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing depletions: %+v", err)
return err
}
if depletionsMatch(existingDepletions, req.Depletions) {
hasDepletionChanges = false
}
}
var existingEggs []entity.RecordingEgg
if hasEggChanges {
existingEggs, err = s.Repository.ListEggs(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing eggs: %+v", err)
return err
}
if eggsMatch(existingEggs, req.Eggs) {
hasEggChanges = false
}
}
if !hasStockChanges && !hasDepletionChanges && !hasEggChanges {
return nil
}
@@ -355,39 +400,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
if hasStockChanges {
existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing stocks: %+v", err)
return err
}
if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil {
return err
}
if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear stocks: %+v", err)
return err
}
mappedStocks := recordingutil.MapStocks(recordingEntity.Id, req.Stocks)
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
s.Log.Errorf("Failed to update stocks: %+v", err)
return err
}
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil {
return err
}
}
if hasDepletionChanges {
existingDepletions, err := s.Repository.ListDepletions(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing depletions: %+v", err)
return err
}
if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear depletions: %+v", err)
return err
@@ -406,12 +424,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
if hasEggChanges {
existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id)
if err != nil {
s.Log.Errorf("Failed to list existing eggs: %+v", err)
return err
}
if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear eggs: %+v", err)
return err
@@ -429,7 +441,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
}
if hasStockChanges || hasDepletionChanges {
if hasStockChanges || hasDepletionChanges || hasEggChanges {
if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil {
s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
return err
@@ -680,12 +692,27 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.
if stock.UsageQty != nil {
desired = *stock.UsageQty
}
var pending float64
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
desiredTotal := desired + pending
if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) {
s.Log.WithFields(logrus.Fields{
"recording_stock_id": stock.Id,
"product_warehouse_id": stock.ProductWarehouseId,
"desired_usage_qty": desired,
"desired_pending_qty": pending,
"desired_total_qty": desiredTotal,
}).Debug("recording fifo consume start")
}
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
ProductWarehouseID: stock.ProductWarehouseId,
Quantity: desired,
Quantity: desiredTotal,
AllowPending: true,
Tx: tx,
})
@@ -694,6 +721,17 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.
return err
}
if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) {
s.Log.WithFields(logrus.Fields{
"recording_stock_id": stock.Id,
"product_warehouse_id": stock.ProductWarehouseId,
"result_usage_qty": result.UsageQuantity,
"result_pending_qty": result.PendingQuantity,
"released_qty": result.ReleasedQuantity,
"added_allocations": len(result.AddedAllocations),
}).Debug("recording fifo consume result")
}
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err
}
@@ -716,6 +754,23 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.
continue
}
var usage float64
var pending float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) {
s.Log.WithFields(logrus.Fields{
"recording_stock_id": stock.Id,
"product_warehouse_id": stock.ProductWarehouseId,
"current_usage_qty": usage,
"current_pending_qty": pending,
}).Debug("recording fifo release start")
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
@@ -771,6 +826,288 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context,
return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx })
}
type desiredStock struct {
Usage float64
Pending float64
}
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock {
desired := make([]desiredStock, len(stocks))
for i := range stocks {
if stocks[i].UsageQty != nil {
desired[i].Usage = *stocks[i].UsageQty
}
if stocks[i].PendingQty != nil {
desired[i].Pending = *stocks[i].PendingQty
}
if !enabled {
continue
}
zero := 0.0
stocks[i].UsageQty = &zero
stocks[i].PendingQty = &zero
}
return desired
}
func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) {
if !enabled {
return
}
for i := range stocks {
if i >= len(desired) {
break
}
usage := desired[i].Usage
pending := desired[i].Pending
stocks[i].UsageQty = &usage
stocks[i].PendingQty = &pending
}
}
func (s *recordingService) syncRecordingStocks(
ctx context.Context,
tx *gorm.DB,
recordingID uint,
existing []entity.RecordingStock,
incoming []validation.Stock,
) error {
if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil {
return err
}
mapped := recordingutil.MapStocks(recordingID, incoming)
return s.Repository.CreateStocks(tx, mapped)
}
existingByWarehouse := make(map[uint][]entity.RecordingStock)
for _, stock := range existing {
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
}
stocksToConsume := make([]entity.RecordingStock, 0, len(incoming))
for _, item := range incoming {
list := existingByWarehouse[item.ProductWarehouseId]
var stock entity.RecordingStock
if len(list) > 0 {
stock = list[0]
existingByWarehouse[item.ProductWarehouseId] = list[1:]
} else {
zero := 0.0
stock = entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
UsageQty: &zero,
PendingQty: &zero,
}
if err := tx.Create(&stock).Error; err != nil {
return err
}
}
desired := item.Qty
stock.UsageQty = &desired
if item.PendingQty != nil {
pending := *item.PendingQty
stock.PendingQty = &pending
}
stocksToConsume = append(stocksToConsume, stock)
}
var leftovers []entity.RecordingStock
for _, list := range existingByWarehouse {
leftovers = append(leftovers, list...)
}
if len(leftovers) > 0 {
if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil {
return err
}
ids := make([]uint, 0, len(leftovers))
for _, stock := range leftovers {
if stock.Id != 0 {
ids = append(ids, stock.Id)
}
}
if len(ids) > 0 {
if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil {
return err
}
}
}
if len(stocksToConsume) == 0 {
return nil
}
return s.consumeRecordingStocks(ctx, tx, stocksToConsume)
}
type eggTotals struct {
Qty int
Weight float64
}
type stockTotals struct {
Usage float64
Pending float64
Total float64
}
func summarizeExistingStocks(stocks []entity.RecordingStock) map[uint]stockTotals {
totals := make(map[uint]stockTotals)
for _, stock := range stocks {
var usage float64
var pending float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
current := totals[stock.ProductWarehouseId]
current.Usage += usage
current.Pending += pending
current.Total += usage + pending
totals[stock.ProductWarehouseId] = current
}
return totals
}
func summarizeIncomingStocks(stocks []validation.Stock) map[uint]stockTotals {
totals := make(map[uint]stockTotals)
for _, stock := range stocks {
var pending float64
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
current := totals[stock.ProductWarehouseId]
current.Usage += stock.Qty
current.Pending += pending
current.Total += stock.Qty + pending
totals[stock.ProductWarehouseId] = current
}
return totals
}
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool {
hasPending := false
for _, item := range incoming {
if item.PendingQty != nil {
hasPending = true
break
}
}
existingUsage := make(map[uint]float64)
existingTotal := make(map[uint]float64)
for _, stock := range existing {
var usage float64
var pending float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
existingUsage[stock.ProductWarehouseId] += usage
existingTotal[stock.ProductWarehouseId] += usage + pending
}
incomingUsage := make(map[uint]float64)
incomingTotal := make(map[uint]float64)
for _, item := range incoming {
var pending float64
if item.PendingQty != nil {
pending = *item.PendingQty
}
incomingUsage[item.ProductWarehouseId] += item.Qty
incomingTotal[item.ProductWarehouseId] += item.Qty + pending
}
if hasPending {
return floatMapsMatch(existingTotal, incomingTotal)
}
return floatMapsMatch(existingUsage, incomingUsage)
}
func depletionsMatch(existing []entity.RecordingDepletion, incoming []validation.Depletion) bool {
existingTotals := make(map[uint]float64)
for _, dep := range existing {
existingTotals[dep.ProductWarehouseId] += dep.Qty
}
incomingTotals := make(map[uint]float64)
for _, dep := range incoming {
incomingTotals[dep.ProductWarehouseId] += dep.Qty
}
return floatMapsMatch(existingTotals, incomingTotals)
}
func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool {
existingTotals := make(map[uint]eggTotals)
for _, egg := range existing {
weight := 0.0
if egg.Weight != nil {
weight = *egg.Weight
}
current := existingTotals[egg.ProductWarehouseId]
current.Qty += egg.Qty
current.Weight += float64(egg.Qty) * weight
existingTotals[egg.ProductWarehouseId] = current
}
incomingTotals := make(map[uint]eggTotals)
for _, egg := range incoming {
weight := 0.0
if egg.Weight != nil {
weight = *egg.Weight
}
current := incomingTotals[egg.ProductWarehouseId]
current.Qty += egg.Qty
current.Weight += float64(egg.Qty) * weight
incomingTotals[egg.ProductWarehouseId] = current
}
if len(existingTotals) != len(incomingTotals) {
return false
}
for key, existingTotal := range existingTotals {
incomingTotal, ok := incomingTotals[key]
if !ok {
return false
}
if existingTotal.Qty != incomingTotal.Qty {
return false
}
if !floatNearlyEqual(existingTotal.Weight, incomingTotal.Weight) {
return false
}
}
return true
}
func floatMapsMatch(a, b map[uint]float64) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
other, ok := b[key]
if !ok {
return false
}
if !floatNearlyEqual(value, other) {
return false
}
}
return true
}
func floatNearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= 0.000001
}
func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error {
day := 0
if recording.Day != nil {