From e0dd2799fc857a8f5815ddb03772d8f962076b44 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 15:10:06 +0700 Subject: [PATCH 1/4] feat(BE): fix fifo system recording and uniformity dto --- .../repository/common.base.repository.go | 3 +- .../common/service/common.fifo.service.go | 2 +- .../recordings/services/recording.service.go | 34 ++++++++++-- .../controllers/uniformity.controller.go | 4 ++ .../services/uniformity.service.go | 52 +++++++++++++------ 5 files changed, 73 insertions(+), 22 deletions(-) diff --git a/internal/common/repository/common.base.repository.go b/internal/common/repository/common.base.repository.go index fa58fcd7..27eea03a 100644 --- a/internal/common/repository/common.base.repository.go +++ b/internal/common/repository/common.base.repository.go @@ -187,10 +187,11 @@ func (r *BaseRepositoryImpl[T]) PatchOne( updates map[string]any, modifier func(*gorm.DB) *gorm.DB, ) error { - q := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id) + q := r.db.WithContext(ctx) if modifier != nil { q = modifier(q) } + q = q.Model(new(T)).Where("id = ?", id) result := q.Updates(updates) if result.Error != nil { diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index bf97f831..35aa2a5a 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -715,7 +715,7 @@ func (s *fifoService) releaseUsagePortion( } } else { if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{ - "quantity": allocation.Qty - releaseAmt, + "qty": allocation.Qty - releaseAmt, }, func(db *gorm.DB) *gorm.DB { return s.txOrDB(tx, db) }); err != nil { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5b09d003..946aa5b3 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -229,7 +229,13 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent CreatedBy: actorID, } - if err := s.Repository.CreateOne(ctx, &createdRecording, func(*gorm.DB) *gorm.DB { return tx }); err != nil { + createTx := tx.WithContext(ctx).Select( + "ProjectFlockKandangId", + "RecordDatetime", + "Day", + "CreatedBy", + ) + if err := createTx.Create(&createdRecording).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return fiber.NewError( fiber.StatusBadRequest, @@ -299,9 +305,11 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin ctx := c.Context() var recordingEntity *entity.Recording + var updatedRecording *entity.Recording transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - recording, err := s.Repository.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { - return s.Repository.WithRelations(tx) + repoTx := s.Repository.WithTx(tx) + recording, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -470,13 +478,31 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } + updated, err := repoTx.GetByID(ctx, recordingEntity.Id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) + }) + if err != nil { + s.Log.Errorf("Failed to reload recording %d after update: %+v", recordingEntity.Id, err) + return err + } + updatedRecording = updated + return nil }) if transactionErr != nil { return nil, transactionErr } - return s.GetOne(c, id) + if updatedRecording == nil { + return s.GetOne(c, id) + } + if err := s.attachLatestApproval(ctx, updatedRecording); err != nil { + return nil, err + } + if err := s.attachProductionStandard(ctx, updatedRecording); err != nil { + return nil, err + } + return updatedRecording, nil } func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) { diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go index 4edf357b..ce91c3af 100644 --- a/internal/modules/production/uniformities/controllers/uniformity.controller.go +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -93,6 +93,10 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { Uniformity: result.Uniformity, Cv: result.Cv, } + document, documentURL, err = u.UniformityService.GetDocumentInfo(c, id) + if err != nil { + return err + } } standard, err := u.UniformityService.GetStandard(c, result) diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index c999867d..318fabc0 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -39,6 +39,7 @@ type UniformityService interface { Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) + GetDocumentInfo(ctx *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) } @@ -592,28 +593,19 @@ func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (Uniform return computeUniformity(rows) } +func (s uniformityService) GetDocumentInfo(c *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) { + return s.fetchUniformityDocument(c.Context(), uniformityID, true) +} + func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) { - if s.DocumentSvc == nil { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusInternalServerError, "Document service not available") - } - - documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(uniformityID)) + document, url, err := s.fetchUniformityDocument(c.Context(), uniformityID, false) if err != nil { return UniformityCalculation{}, nil, "", err } - if len(documents) == 0 { + if document == nil || url == "" { return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") } - document := documents[0] - url, err := s.DocumentSvc.PresignURL(c.Context(), document, 15*time.Minute) - if err != nil { - return UniformityCalculation{}, nil, "", err - } - if url == "" { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available") - } - req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil) if err != nil { return UniformityCalculation{}, nil, "", err @@ -638,7 +630,35 @@ func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniform return UniformityCalculation{}, nil, "", err } - return calculation, &document, url, nil + return calculation, document, url, nil +} + +func (s uniformityService) fetchUniformityDocument(ctx context.Context, uniformityID uint, allowMissing bool) (*entity.Document, string, error) { + if s.DocumentSvc == nil { + return nil, "", fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } + + documents, err := s.DocumentSvc.ListByTarget(ctx, "UNIFORMITY", uint64(uniformityID)) + if err != nil { + return nil, "", err + } + if len(documents) == 0 { + if allowMissing { + return nil, "", nil + } + return nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + } + + document := documents[0] + url, err := s.DocumentSvc.PresignURL(ctx, document, 15*time.Minute) + if err != nil { + return nil, "", err + } + if url == "" { + return nil, "", fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available") + } + + return &document, url, nil } func (s *uniformityService) createUniformityApproval( From fe51f33ab4ad5e1110042fe875ca7974d981b854 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 19:30:04 +0700 Subject: [PATCH 2/4] feat(BE): fixing fifo system recording --- .../common/service/common.fifo.service.go | 63 +++ .../services/adjustment.service.go | 26 +- .../repositories/recording.repository.go | 13 +- .../recordings/services/recording.service.go | 409 ++++++++++++++++-- 4 files changed, 451 insertions(+), 60 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 35aa2a5a..5b7adc2e 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -192,6 +192,16 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St if req.Quantity < 0 { return nil, errors.New("quantity must be zero or greater") } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "requested_quantity": req.Quantity, + "allow_pending": req.AllowPending, + "product_warehouse_id": req.ProductWarehouseID, + }).Debug("fifo consume request") + } + cfg, ok := fifo.Usable(req.UsableKey) if !ok { @@ -220,6 +230,19 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St currentPending := ctxRow.PendingQty currentTotal := currentUsage + currentPending delta := req.Quantity - currentTotal + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "product_warehouse_id": productWarehouseID, + "current_usage_qty": currentUsage, + "current_pending_qty": currentPending, + "current_total_qty": currentTotal, + "requested_quantity": req.Quantity, + "calculated_delta": delta, + "input_warehouse_match": req.ProductWarehouseID == 0 || req.ProductWarehouseID == productWarehouseID, + }).Debug("fifo consume context") + } var ( usageDelta float64 @@ -285,6 +308,20 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St result.ReleasedQuantity = releasedAmount result.UsageQuantity = currentUsage + usageDelta result.PendingQuantity = currentPending + pendingDelta + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "product_warehouse_id": productWarehouseID, + "usage_delta": usageDelta, + "pending_delta": pendingDelta, + "released_quantity": releasedAmount, + "added_allocations": len(addedAlloc), + "final_usage_qty": result.UsageQuantity, + "final_pending_qty": result.PendingQuantity, + "final_requested_qty": result.RequestedQuantity, + }).Debug("fifo consume result") + } return nil }) @@ -299,6 +336,13 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { return errors.New("usable key and id are required") } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "reason": req.Reason, + }).Debug("fifo release request") + } return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { cfg, ok := fifo.Usable(req.UsableKey) @@ -310,6 +354,16 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if err != nil { return err } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "product_warehouse_id": ctxRow.ProductWarehouseID, + "current_usage_qty": ctxRow.UsageQty, + "current_pending_qty": ctxRow.PendingQty, + "current_total_qty": ctxRow.UsageQty + ctxRow.PendingQty, + }).Debug("fifo release context") + } var usageDelta, pendingDelta float64 if ctxRow.UsageQty > 0 { @@ -326,6 +380,15 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) return err } + if s.logger.IsLevelEnabled(logrus.DebugLevel) { + s.logger.WithFields(logrus.Fields{ + "usable_key": req.UsableKey.String(), + "usable_id": req.UsableID, + "usage_delta": usageDelta, + "pending_delta": pendingDelta, + }).Debug("fifo release applied") + } + return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB { return s.txOrDB(tx, db) }) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index f15f37df..47d41648 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -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: ¬e, @@ -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) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d75060ad..941d4507 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -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 diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 946aa5b3..c9ca74f5 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -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 { From cc5a58b6d1af7a9fbd8cecfb19ce2819031e0037 Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 2 Jan 2026 12:04:50 +0700 Subject: [PATCH 3/4] feat(BE): sso delete and fix response too many request --- ...02045853_fix_soft_delete_fk_casts.down.sql | 126 ++++++++++++++++ ...0102045853_fix_soft_delete_fk_casts.up.sql | 142 ++++++++++++++++++ .../modules/sso/controllers/sso.controller.go | 3 + .../users/repositories/user.repository.go | 4 +- 4 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql create mode 100644 internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql diff --git a/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql new file mode 100644 index 00000000..161d3d89 --- /dev/null +++ b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)', + fk.child_table, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql new file mode 100644 index 00000000..2801ac2e --- /dev/null +++ b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql @@ -0,0 +1,142 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; + child_type text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + SELECT format_type(atttypid, atttypmod) INTO child_type + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = child_column + AND NOT attisdropped; + + IF child_type IS NULL THEN + child_type := 'text'; + END IF; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1::%s %s)', + fk.child_table, + child_column, + child_type, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1::%s %s', + fk.child_table, + child_column, + child_column, + child_type, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1::%s AND deleted_at IS NULL', + fk.child_table, + child_column, + child_type + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1::%s', + fk.child_table, + child_column, + child_type + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1::%s %s', + fk.child_table, + child_column, + child_column, + child_type, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 99bd67d6..554b3388 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -171,6 +171,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { if resp.StatusCode >= 400 { utils.Log.Warnf("token refresh response status %d", resp.StatusCode) + if resp.StatusCode == fiber.StatusTooManyRequests { + return fiber.NewError(fiber.StatusTooManyRequests, "Too many attempts, please slow down") + } return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") } diff --git a/internal/modules/users/repositories/user.repository.go b/internal/modules/users/repositories/user.repository.go index f9bee9ed..b3cac2dc 100644 --- a/internal/modules/users/repositories/user.repository.go +++ b/internal/modules/users/repositories/user.repository.go @@ -42,7 +42,7 @@ func (r *UserRepositoryImpl) GetByIdUser( modifier func(*gorm.DB) *gorm.DB, ) (*entity.User, error) { return r.BaseRepositoryImpl.First(ctx, func(db *gorm.DB) *gorm.DB { - return db.Where("id_user = ?", idUser) + return db.Where("id_user::bigint = ?::bigint", idUser) }) } @@ -93,7 +93,7 @@ func (r *UserRepositoryImpl) UpsertByIdUser(ctx context.Context, user *entity.Us } func (r *UserRepositoryImpl) SoftDeleteByIdUser(ctx context.Context, idUser int64) error { - query := r.DB().WithContext(ctx).Where("id_user = ?", idUser) + query := r.DB().WithContext(ctx).Where("id_user::bigint = ?::bigint", idUser) result := query.Delete(&entity.User{}) if result.Error != nil { return result.Error From 8de33a0f24e86331955aba70d4377853262c20de Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 2 Jan 2026 20:43:57 +0700 Subject: [PATCH 4/4] feat(BE): fix delete project flock budget and uniformity, and fix uniformity with update purchase document --- .../common/service/common.document.service.go | 51 +++++++++++ .../common/service/common.fifo.service.go | 68 -------------- ...uniformity_project_budget_cascade.down.sql | 86 ++++++++++++++++++ ...e_uniformity_project_budget_cascade.up.sql | 90 +++++++++++++++++++ .../project_flock_kandang_uniformity.go | 9 +- .../services/projectflock.service.go | 9 ++ .../recordings/services/recording.service.go | 45 ---------- .../uniformities/dto/uniformity.dto.go | 2 - .../repositories/uniformity.repository.go | 13 +++ .../services/uniformity.service.go | 2 +- .../controllers/purchase.controller.go | 5 +- .../purchases/services/purchase.service.go | 64 ++++++++++++- internal/utils/constant.go | 4 +- 13 files changed, 320 insertions(+), 128 deletions(-) create mode 100644 internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql create mode 100644 internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql diff --git a/internal/common/service/common.document.service.go b/internal/common/service/common.document.service.go index 079e3eba..44f2c116 100644 --- a/internal/common/service/common.document.service.go +++ b/internal/common/service/common.document.service.go @@ -6,6 +6,7 @@ import ( "fmt" "mime" "mime/multipart" + "net/url" "path/filepath" "strings" "time" @@ -305,6 +306,56 @@ func (s *documentService) PresignURL(ctx context.Context, document entity.Docume return s.storage.PresignURL(ctx, document.Path, expires) } +// ResolveDocumentURL normalizes a stored path or URL into a presigned URL. +func ResolveDocumentURL( + ctx context.Context, + svc DocumentService, + rawPath string, + expires time.Duration, +) (string, error) { + if svc == nil { + return "", nil + } + + rawPath = strings.TrimSpace(rawPath) + if rawPath == "" { + return "", nil + } + + key := rawPath + lower := strings.ToLower(rawPath) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + key = extractS3KeyFromURL(rawPath) + if key == "" { + return "", nil + } + } + + return svc.PresignURL(ctx, entity.Document{Path: key}, expires) +} + +func extractS3KeyFromURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "" + } + path := strings.TrimPrefix(parsed.Path, "/") + if path == "" { + return "" + } + + host := strings.ToLower(strings.TrimSpace(parsed.Host)) + if strings.HasPrefix(host, "s3.") || strings.HasPrefix(host, "s3-") { + parts := strings.SplitN(path, "/", 2) + if len(parts) == 2 { + return parts[1] + } + return "" + } + + return path +} + func (s *documentService) generateObjectKey(ext string) (string, error) { normalizedExt := strings.TrimSpace(ext) if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") { diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 5b7adc2e..2a65c1b4 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -192,17 +192,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St if req.Quantity < 0 { return nil, errors.New("quantity must be zero or greater") } - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "requested_quantity": req.Quantity, - "allow_pending": req.AllowPending, - "product_warehouse_id": req.ProductWarehouseID, - }).Debug("fifo consume request") - } - - cfg, ok := fifo.Usable(req.UsableKey) if !ok { return nil, fmt.Errorf("usable %q is not registered", req.UsableKey) @@ -230,20 +219,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St currentPending := ctxRow.PendingQty currentTotal := currentUsage + currentPending delta := req.Quantity - currentTotal - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "product_warehouse_id": productWarehouseID, - "current_usage_qty": currentUsage, - "current_pending_qty": currentPending, - "current_total_qty": currentTotal, - "requested_quantity": req.Quantity, - "calculated_delta": delta, - "input_warehouse_match": req.ProductWarehouseID == 0 || req.ProductWarehouseID == productWarehouseID, - }).Debug("fifo consume context") - } - var ( usageDelta float64 pendingDelta float64 @@ -308,21 +283,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St result.ReleasedQuantity = releasedAmount result.UsageQuantity = currentUsage + usageDelta result.PendingQuantity = currentPending + pendingDelta - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "product_warehouse_id": productWarehouseID, - "usage_delta": usageDelta, - "pending_delta": pendingDelta, - "released_quantity": releasedAmount, - "added_allocations": len(addedAlloc), - "final_usage_qty": result.UsageQuantity, - "final_pending_qty": result.PendingQuantity, - "final_requested_qty": result.RequestedQuantity, - }).Debug("fifo consume result") - } - return nil }) if err != nil { @@ -336,14 +296,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { return errors.New("usable key and id are required") } - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "reason": req.Reason, - }).Debug("fifo release request") - } - return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { cfg, ok := fifo.Usable(req.UsableKey) if !ok { @@ -354,17 +306,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) if err != nil { return err } - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "product_warehouse_id": ctxRow.ProductWarehouseID, - "current_usage_qty": ctxRow.UsageQty, - "current_pending_qty": ctxRow.PendingQty, - "current_total_qty": ctxRow.UsageQty + ctxRow.PendingQty, - }).Debug("fifo release context") - } - var usageDelta, pendingDelta float64 if ctxRow.UsageQty > 0 { if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil { @@ -380,15 +321,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) return err } - if s.logger.IsLevelEnabled(logrus.DebugLevel) { - s.logger.WithFields(logrus.Fields{ - "usable_key": req.UsableKey.String(), - "usable_id": req.UsableID, - "usage_delta": usageDelta, - "pending_delta": pendingDelta, - }).Debug("fifo release applied") - } - return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB { return s.txOrDB(tx, db) }) diff --git a/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql new file mode 100644 index 00000000..b702016c --- /dev/null +++ b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql @@ -0,0 +1,86 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs (id) + ON DELETE RESTRICT ON UPDATE CASCADE; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'project_budgets' + ) THEN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_budgets_project_flock_id' + ) THEN + ALTER TABLE project_budgets + DROP CONSTRAINT fk_project_budgets_project_flock_id; + END IF; + + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_project_flock_id + FOREIGN KEY (project_flock_id) + REFERENCES project_flocks(id); + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'project_flock_kandang_uniformity' + ) THEN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'created_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW(); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'updated_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW(); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'deleted_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN deleted_at TIMESTAMPTZ; + END IF; + END IF; +END $$; + +COMMIT; diff --git a/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql new file mode 100644 index 00000000..7a092012 --- /dev/null +++ b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql @@ -0,0 +1,90 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs (id) + ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'project_budgets' + ) THEN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_budgets_project_flock_id' + ) THEN + ALTER TABLE project_budgets + DROP CONSTRAINT fk_project_budgets_project_flock_id; + END IF; + + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_project_flock_id + FOREIGN KEY (project_flock_id) + REFERENCES project_flocks(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_trigger + WHERE tgname = 'trg_soft_delete_fk_project_flock_kandang_uniformity' + ) THEN + DROP TRIGGER trg_soft_delete_fk_project_flock_kandang_uniformity + ON project_flock_kandang_uniformity; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'created_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN created_at; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'updated_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN updated_at; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'deleted_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN deleted_at; + END IF; +END $$; + +COMMIT; diff --git a/internal/entities/project_flock_kandang_uniformity.go b/internal/entities/project_flock_kandang_uniformity.go index ecf90d19..bf320c72 100644 --- a/internal/entities/project_flock_kandang_uniformity.go +++ b/internal/entities/project_flock_kandang_uniformity.go @@ -1,10 +1,6 @@ package entities -import ( - "time" - - "gorm.io/gorm" -) +import "time" type ProjectFlockKandangUniformity struct { Id uint `gorm:"primaryKey"` @@ -18,9 +14,6 @@ type ProjectFlockKandangUniformity struct { UniformQty float64 `gorm:"type:numeric(15,3)"` NotUniformQty float64 `gorm:"type:numeric(15,3)"` UniformDate *time.Time `gorm:"type:timestamptz"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` CreatedBy uint `gorm:"not null"` ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 1e859e47..ec887eea 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -22,6 +22,7 @@ import ( pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -866,6 +867,14 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * } if len(pfkIDs) > 0 { + uniformityRepo := uniformityRepository.NewUniformityRepository(s.Repository.DB()) + if dbTransaction != nil { + uniformityRepo = uniformityRepository.NewUniformityRepository(dbTransaction) + } + if err := uniformityRepo.DeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove uniformity data for project flock kandang") + } + pwRepo := s.ProductWarehouseRepo if dbTransaction != nil { pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index c9ca74f5..54052518 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -333,13 +333,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin 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 } @@ -698,16 +691,6 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. } 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, @@ -721,17 +704,6 @@ 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 } @@ -754,23 +726,6 @@ 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, diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go index 4a813b98..0c38d81b 100644 --- a/internal/modules/production/uniformities/dto/uniformity.dto.go +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -74,7 +74,6 @@ type UniformityListDTO struct { MeanDown float64 `json:"mean_down"` StandardMeanWeight *float64 `json:"standard_mean_weight"` StandardUniformity *float64 `json:"standard_uniformity"` - CreatedAt time.Time `json:"created_at"` CreatedBy uint `json:"created_by"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } @@ -154,7 +153,6 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor UniformQty: item.UniformQty, MeanUp: item.MeanUp, MeanDown: item.MeanDown, - CreatedAt: item.CreatedAt, CreatedBy: item.CreatedBy, LatestApproval: latestApproval, } diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go index 3bc66f4f..241dea49 100644 --- a/internal/modules/production/uniformities/repositories/uniformity.repository.go +++ b/internal/modules/production/uniformities/repositories/uniformity.repository.go @@ -1,6 +1,8 @@ package repository import ( + "context" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -8,6 +10,7 @@ import ( type UniformityRepository interface { repository.BaseRepository[entity.ProjectFlockKandangUniformity] + DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error } type UniformityRepositoryImpl struct { @@ -19,3 +22,13 @@ func NewUniformityRepository(db *gorm.DB) UniformityRepository { BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockKandangUniformity](db), } } + +func (r *UniformityRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { + if len(projectFlockKandangIDs) == 0 { + return nil + } + return r.DB().WithContext(ctx). + Unscoped(). + Where("project_flock_kandang_id IN ?", projectFlockKandangIDs). + Delete(&entity.ProjectFlockKandangUniformity{}).Error +} diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 318fabc0..fb7ed9ed 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -99,7 +99,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent if params.Week != 0 { db = db.Where("week = ?", params.Week) } - return db.Order("uniform_date DESC").Order("created_at DESC") + return db.Order("uniform_date DESC").Order("id DESC") }) if err != nil { diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index d9b32cd1..977b4ac1 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -180,7 +180,10 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { req.Items = []validation.ReceivePurchaseItemRequest{singleItem} } } - req.TravelDocuments = form.File["documents"] + req.TravelDocuments = form.File["travel_documents"] + if len(req.TravelDocuments) == 0 { + req.TravelDocuments = form.File["documents"] + } result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 7dac0e19..68b21d6a 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -999,6 +999,22 @@ func (s *purchaseService) uploadTravelDocument( return "", errors.New("document service not available") } + documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(itemID)) + if err != nil { + return "", err + } + if len(documents) > 0 { + var ids []uint + for _, doc := range documents { + if doc.Type == string(utils.DocumentTypePurchaseTravel) { + ids = append(ids, doc.Id) + } + } + if err := s.DocumentSvc.DeleteDocuments(ctx, ids, true); err != nil { + return "", err + } + } + documentFiles := []commonSvc.DocumentFile{{ File: file, Type: string(utils.DocumentTypePurchaseTravel), @@ -1015,7 +1031,7 @@ func (s *purchaseService) uploadTravelDocument( if len(results) == 0 { return "", errors.New("upload result is empty") } - return results[0].URL, nil + return results[0].Document.Path, nil } func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { @@ -1499,10 +1515,56 @@ func (s *purchaseService) loadPurchase( if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } + s.applyTravelDocumentURLs(ctx, purchase) return purchase, nil } +func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase *entity.Purchase) { + if purchase == nil || s.DocumentSvc == nil { + return + } + + for i := range purchase.Items { + item := &purchase.Items[i] + documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id)) + if err != nil { + s.Log.Warnf("Unable to load travel documents for purchase item %d: %+v", item.Id, err) + } else { + var targetDoc *entity.Document + for j := len(documents) - 1; j >= 0; j-- { + if documents[j].Type == string(utils.DocumentTypePurchaseTravel) { + targetDoc = &documents[j] + break + } + } + if targetDoc != nil { + url, err := s.DocumentSvc.PresignURL(ctx, *targetDoc, 15*time.Minute) + if err != nil { + s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err) + } else if url != "" { + item.TravelNumberDocs = &url + continue + } + } + } + + path := item.TravelNumberDocs + if path == nil || strings.TrimSpace(*path) == "" { + continue + } + url, err := commonSvc.ResolveDocumentURL(ctx, s.DocumentSvc, *path, 15*time.Minute) + if err != nil { + s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err) + continue + } + if url == "" { + continue + } + item.TravelNumberDocs = &url + } +} + func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { seen := make(map[uint]struct{}) ids := make([]uint, 0) diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 34334166..6ec50447 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -426,12 +426,12 @@ const ( DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" - DocumentTypePurchaseTravel DocumentType = "PURCHASE_TRAVEL_DOCUMENT" + DocumentTypePurchaseTravel DocumentType = "PURCHASE_TRAVEL_DOCUMENT" DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" DocumentableTypeExpense DocumentableType = "EXPENSE" DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" - DocumentableTypePurchaseItem DocumentableType = "PURCHASE_ITEM" + DocumentableTypePurchaseItem DocumentableType = "PURCHASE_ITEM" ) // -------------------------------------------------------------------