diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go index 724c6376..a29d4173 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -21,6 +21,7 @@ type PurchaseItem struct { Price float64 `gorm:"type:numeric(15,3);default:0"` TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` ExpenseNonstockId *uint64 + HasChickin bool `gorm:"-" json:"-"` // Relations ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 7132644e..188c4506 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -100,9 +100,13 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) offset := (params.Page - 1) * params.Limit + var marketingTypes []string if params.Type != "" { - if !utils.IsValidMarketingType(params.Type) { - return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type") + marketingTypes = utils.ParseQueryArray(params.Type) + for _, t := range marketingTypes { + if !utils.IsValidMarketingType(t) { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type") + } } } @@ -135,16 +139,42 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } - if params.Type != "" { - switch params.Type { - case string(utils.MarketingTypeAyamPullet): - db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)}) - case string(utils.MarketingTypeAyam): - db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagAyamMati)}) - case string(utils.MarketingTypeTelur): - db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagTelur), string(utils.FlagTelurUtuh), string(utils.FlagTelurPecah), string(utils.FlagTelurPutih), string(utils.FlagTelurRetak)}) - case string(utils.MarketingTypeTrading): - db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher), string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), string(utils.FlagEkspedisi)}) + if len(marketingTypes) > 0 { + flagSet := make(map[string]struct{}) + for _, t := range marketingTypes { + switch t { + case string(utils.MarketingTypeAyamPullet): + flagSet[string(utils.FlagDOC)] = struct{}{} + flagSet[string(utils.FlagPullet)] = struct{}{} + flagSet[string(utils.FlagLayer)] = struct{}{} + case string(utils.MarketingTypeAyam): + flagSet[string(utils.FlagAyamAfkir)] = struct{}{} + flagSet[string(utils.FlagAyamCulling)] = struct{}{} + flagSet[string(utils.FlagAyamMati)] = struct{}{} + case string(utils.MarketingTypeTelur): + flagSet[string(utils.FlagTelur)] = struct{}{} + flagSet[string(utils.FlagTelurUtuh)] = struct{}{} + flagSet[string(utils.FlagTelurPecah)] = struct{}{} + flagSet[string(utils.FlagTelurPutih)] = struct{}{} + flagSet[string(utils.FlagTelurRetak)] = struct{}{} + case string(utils.MarketingTypeTrading): + flagSet[string(utils.FlagPakan)] = struct{}{} + flagSet[string(utils.FlagPreStarter)] = struct{}{} + flagSet[string(utils.FlagStarter)] = struct{}{} + flagSet[string(utils.FlagFinisher)] = struct{}{} + flagSet[string(utils.FlagOVK)] = struct{}{} + flagSet[string(utils.FlagObat)] = struct{}{} + flagSet[string(utils.FlagVitamin)] = struct{}{} + flagSet[string(utils.FlagKimia)] = struct{}{} + flagSet[string(utils.FlagEkspedisi)] = struct{}{} + } + } + if len(flagSet) > 0 { + flags := make([]string, 0, len(flagSet)) + for f := range flagSet { + flags = append(flags, f) + } + db = s.Repository.ApplyFlagsFilter(db, flags) } } diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 7e7da7a6..5d1f4e0a 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -20,5 +20,5 @@ type Query struct { Flags string `query:"flags" validate:"omitempty"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` - Type string `query:"type" validate:"omitempty,oneof=AYAM TELUR TRADING AYAM_PULLET"` + Type string `query:"type" validate:"omitempty"` } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index b9b1126e..f789144c 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -692,6 +692,13 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent ); err != nil { return err } + + if action == entity.ApprovalActionRejected { + note := fmt.Sprintf("Recording-Reject#%d", id) + if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { + return err + } + } } return nil @@ -729,43 +736,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { note := fmt.Sprintf("Recording-Delete#%d", id) return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - oldDepletions, err := s.Repository.ListDepletions(tx, id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list depletions before delete: %+v", err) - return err - } - if s.FifoSvc != nil { - if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { - return err - } - } - - oldEggs, err := s.Repository.ListEggs(tx, id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list eggs before delete: %+v", err) - return err - } - if s.FifoSvc != nil { - if err := ensureRecordingEggsUnused(oldEggs); err != nil { - return err - } - } - - oldStocks, err := s.Repository.ListStocks(tx, id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list stocks before delete: %+v", err) - return err - } - - if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { - return err - } - - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { - return err - } - - if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { + if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err } @@ -781,6 +752,51 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { }) } +func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error { + if recordingID == 0 || tx == nil { + return nil + } + + oldDepletions, err := s.Repository.ListDepletions(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list depletions: %+v", err) + return err + } + + oldEggs, err := s.Repository.ListEggs(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list eggs: %+v", err) + return err + } + if s.FifoSvc != nil { + if err := ensureRecordingEggsUnused(oldEggs); err != nil { + return err + } + if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { + return err + } + } + + oldStocks, err := s.Repository.ListStocks(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list stocks: %+v", err) + return err + } + if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { + return err + } + + if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { + return err + } + + return nil +} + // === Persistence Helpers === func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index c8df2294..849ffd63 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -67,6 +67,7 @@ type PurchaseItemDTO struct { VehicleNumber *string `json:"vehicle_number"` TransportPerItem *float64 `json:"transport_per_item,omitempty"` ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` + HasChickin bool `json:"has_chickin"` } type PoExpeditionDTO struct { @@ -100,6 +101,7 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { TravelNumber: item.TravelNumber, TravelDocumentPath: item.TravelNumberDocs, VehicleNumber: item.VehicleNumber, + HasChickin: item.HasChickin, } if item.Product != nil && item.Product.Id != 0 { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 703c04b9..6ecc6e1f 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -255,6 +255,39 @@ func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error if err := s.attachLatestApproval(c.Context(), purchase); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } + if len(purchase.Items) > 0 { + itemIDs := make([]uint, 0, len(purchase.Items)) + for i := range purchase.Items { + if purchase.Items[i].Id == 0 { + continue + } + itemIDs = append(itemIDs, purchase.Items[i].Id) + } + if len(itemIDs) > 0 { + var usedIDs []uint + if err := s.PurchaseRepo.DB().WithContext(c.Context()). + Model(&entity.StockAllocation{}). + Distinct("stockable_id"). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + ). + Pluck("stockable_id", &usedIDs).Error; err != nil { + return nil, err + } + usedSet := make(map[uint]struct{}, len(usedIDs)) + for _, id := range usedIDs { + usedSet[id] = struct{}{} + } + for i := range purchase.Items { + if _, ok := usedSet[purchase.Items[i].Id]; ok { + purchase.Items[i].HasChickin = true + } + } + } + } s.applyTravelDocumentURLs(c.Context(), purchase) return purchase, nil @@ -498,6 +531,54 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid return nil, utils.BadRequest("Items must not be empty for staff approval") } + if action == entity.ApprovalActionApproved { + itemIDs := make([]uint, 0, len(purchase.Items)) + itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) + for i := range purchase.Items { + if purchase.Items[i].Id == 0 { + continue + } + itemIDs = append(itemIDs, purchase.Items[i].Id) + itemByID[purchase.Items[i].Id] = purchase.Items[i] + } + if len(itemIDs) > 0 { + var usedIDs []uint + if err := s.PurchaseRepo.DB().WithContext(ctx). + Model(&entity.StockAllocation{}). + Distinct("stockable_id"). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + ). + Pluck("stockable_id", &usedIDs).Error; err != nil { + return nil, err + } + if len(usedIDs) > 0 { + usedSet := make(map[uint]struct{}, len(usedIDs)) + for _, id := range usedIDs { + usedSet[id] = struct{}{} + } + for _, payload := range req.Items { + if payload.PurchaseItemID == 0 || payload.Qty == nil { + continue + } + if _, used := usedSet[payload.PurchaseItemID]; !used { + continue + } + item, ok := itemByID[payload.PurchaseItemID] + if !ok { + continue + } + if *payload.Qty != item.SubQty { + return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah") + } + } + } + } + } + payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving) if err != nil { return nil, err @@ -745,6 +826,54 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation req.Items[idx].TravelDocumentPath = &uploadedURL } } + if action == entity.ApprovalActionApproved { + itemIDs := make([]uint, 0, len(purchase.Items)) + itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) + for i := range purchase.Items { + if purchase.Items[i].Id == 0 { + continue + } + itemIDs = append(itemIDs, purchase.Items[i].Id) + itemByID[purchase.Items[i].Id] = purchase.Items[i] + } + if len(itemIDs) > 0 { + var usedIDs []uint + if err := s.PurchaseRepo.DB().WithContext(ctx). + Model(&entity.StockAllocation{}). + Distinct("stockable_id"). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + ). + Pluck("stockable_id", &usedIDs).Error; err != nil { + return nil, err + } + if len(usedIDs) > 0 { + usedSet := make(map[uint]struct{}, len(usedIDs)) + for _, id := range usedIDs { + usedSet[id] = struct{}{} + } + for _, payload := range req.Items { + if _, used := usedSet[payload.PurchaseItemID]; !used { + continue + } + item, ok := itemByID[payload.PurchaseItemID] + if !ok { + continue + } + receivedQty := item.SubQty + if payload.ReceivedQty != nil { + receivedQty = *payload.ReceivedQty + } + if receivedQty != item.TotalQty { + return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah") + } + } + } + } + } itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { @@ -1367,6 +1496,30 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + itemIDs := make([]uint, 0, len(itemsToDelete)) + for _, item := range itemsToDelete { + if item.Id == 0 { + continue + } + itemIDs = append(itemIDs, item.Id) + } + if len(itemIDs) > 0 { + var count int64 + if err := tx.Model(&entity.StockAllocation{}). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + ). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return utils.BadRequest("Purchase already chickin, failed to delete purchase") + } + } + if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil { return err } @@ -1383,6 +1536,10 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { return nil }) if transactionErr != nil { + var fe *fiber.Error + if errors.As(transactionErr, &fe) { + return fe + } if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return utils.NotFound("Purchase not found") }