diff --git a/db_lti_erp-202601271102-stg.sql b/db_lti_erp-202601271102-stg.sql new file mode 100644 index 00000000..2a8495d8 Binary files /dev/null and b/db_lti_erp-202601271102-stg.sql differ diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index 97ad3800..e0f2bcc5 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -20,6 +20,7 @@ type HppCostRepository interface { GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) + GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) @@ -219,6 +220,24 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang return totals.TotalPieces, totals.TotalWeightKg, nil } +func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) { + var totals struct { + TotalPieces float64 + TotalWeightKg float64 + } + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg"). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Scan(&totals).Error + if err != nil { + return 0, 0, err + } + + return totals.TotalPieces, totals.TotalWeightKg, nil +} + func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds( ctx context.Context, projectFlockKandangIDs []uint, diff --git a/internal/config/config.go b/internal/config/config.go index 71fb430c..af723b3b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,6 +61,7 @@ var ( SSOCookieDomain string SSOCookieSecure bool SSOCookieSameSite string + SSOAccessTokenMaxBytes int SSOTokenBlacklistPrefix string SSOPKCETTL time.Duration SSOUserSyncDrift time.Duration @@ -144,6 +145,10 @@ func init() { SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE") SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax") + SSOAccessTokenMaxBytes = viper.GetInt("SSO_ACCESS_TOKEN_MAX_BYTES") + if SSOAccessTokenMaxBytes <= 0 { + SSOAccessTokenMaxBytes = 4096 + } SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist") if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 { SSOPKCETTL = time.Duration(ttl) * time.Second diff --git a/internal/database/migrations/20260128034158_create_field_stock.down.sql b/internal/database/migrations/20260128034158_create_field_stock.down.sql new file mode 100644 index 00000000..7a508600 --- /dev/null +++ b/internal/database/migrations/20260128034158_create_field_stock.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE stock_logs +DROP COLUMN stock; diff --git a/internal/database/migrations/20260128034158_create_field_stock.up.sql b/internal/database/migrations/20260128034158_create_field_stock.up.sql new file mode 100644 index 00000000..9b7847f1 --- /dev/null +++ b/internal/database/migrations/20260128034158_create_field_stock.up.sql @@ -0,0 +1,18 @@ +ALTER TABLE stock_logs +ADD COLUMN stock NUMERIC(15, 3) NOT NULL DEFAULT 0; + +WITH calc AS ( + SELECT + id, + SUM(COALESCE(increase, 0) - COALESCE(decrease, 0)) + OVER ( + PARTITION BY product_warehouse_id + ORDER BY id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS running_stock + FROM stock_logs +) +UPDATE stock_logs t +SET stock = c.running_stock +FROM calc c +WHERE t.id = c.id; diff --git a/internal/database/migrations/20260129083458_create_transfer_laying_sequence.down.sql b/internal/database/migrations/20260129083458_create_transfer_laying_sequence.down.sql new file mode 100644 index 00000000..5c44b309 --- /dev/null +++ b/internal/database/migrations/20260129083458_create_transfer_laying_sequence.down.sql @@ -0,0 +1,2 @@ +-- Drop transfer laying sequence +DROP SEQUENCE IF EXISTS transfer_laying_seq; \ No newline at end of file diff --git a/internal/database/migrations/20260129083458_create_transfer_laying_sequence.up.sql b/internal/database/migrations/20260129083458_create_transfer_laying_sequence.up.sql new file mode 100644 index 00000000..f5f5bdf7 --- /dev/null +++ b/internal/database/migrations/20260129083458_create_transfer_laying_sequence.up.sql @@ -0,0 +1,33 @@ +-- Create sequence for transfer laying movement number +CREATE SEQUENCE transfer_laying_seq START +WITH + 1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 NO CYCLE; + +-- Set sequence starting value based on existing data (if any) +-- This prevents duplicate movement numbers if there's already data +DO $$ DECLARE max_existing INTEGER; + +BEGIN +-- Check if table exists and has data +IF EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE + table_schema = 'public' + AND table_name = 'transfer_to_layings' +) THEN +-- Get max ID from existing records +SELECT COALESCE(MAX(id), 0) INTO max_existing +FROM transfer_to_layings; + +-- Set sequence to start after the highest existing ID +IF max_existing > 0 THEN PERFORM setval ( + 'transfer_laying_seq', + max_existing +); + +END IF; + +END IF; + +END $$; \ No newline at end of file diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index d6acafb8..1bebdd73 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -9,6 +9,7 @@ type StockLog struct { Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"` Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"` + Stock float64 `gorm:"column:stock;type:numeric(15,3);not null;default:0"` LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"` LoggableId uint `gorm:"column:loggable_id;not null"` diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index a4bb5cb0..189ef7cb 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -101,6 +101,26 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { } } +func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO { + + productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags)) + for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags { + productFlags[i] = f.Name + } + + var category string + if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { + category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category + } + + ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category) + + return SalesDTO{ + Age: ageInDay, + Qty: e.UsageQty, + } +} + func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { var totalSalesPrice, totalActualPrice, sumSales, sumActual float64 diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index bd068843..923a2b1c 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -367,7 +367,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") } - warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) + warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID) if err != nil { s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") @@ -451,7 +451,7 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) + warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID) if err != nil { s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") @@ -494,13 +494,16 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u return items, nil } -func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { +func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint, kandangID *uint) ([]uint, error) { var kandangIDs []uint db := s.Repository.DB().WithContext(ctx) - if err := db.Model(&entity.ProjectFlockKandang{}). - Where("project_flock_id = ?", projectFlockID). - Pluck("kandang_id", &kandangIDs).Error; err != nil { + query := db.Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ?", projectFlockID) + if kandangID != nil && *kandangID > 0 { + query = query.Where("id = ?", *kandangID) + } + if err := query.Pluck("kandang_id", &kandangIDs).Error; err != nil { return nil, err } @@ -841,7 +844,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data") } } - age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID) + age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID, kandangID) if err != nil { s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data") @@ -1028,38 +1031,24 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return &result, nil } -func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint) (float64, error) { - deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(ctx, projectFlockID, func(db *gorm.DB) *gorm.DB { - return db. - Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins") - }) +func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) (float64, error) { + penjualan, err := s.MarketingDeliveryProductRepo.GetClosingPenjualanForAgeChickDataProduction(ctx, projectFlockID, projectFlockKandangID) if err != nil { return 0, err } - - var ( - totalQty float64 - totalAgeWeeks float64 - ) - - for _, product := range deliveryProducts { - if product.UsageQty == 0 { - continue - } - projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang - ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate) - totalAgeWeeks += float64(ageWeeks) * product.UsageQty - totalQty += product.UsageQty + acumulateAgeQty := 0.0 + totalQty := 0.0 + for _, v := range penjualan { + sale := dto.ToSalesAgeDTO(v) + acumulateAgeQty += float64(sale.Age) * sale.Qty + totalQty += sale.Qty + } + if totalQty > 0 { + averageAge := acumulateAgeQty / totalQty + return averageAge, nil } - if totalQty == 0 { - return 0, nil - } - - return totalAgeWeeks / totalQty, nil + return 0, err } func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) { diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index ca76c67e..44137fad 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -262,7 +262,7 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo } if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - _, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(c.Context(), projectFlockKandangIDs, nil) + _, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll(c.Context(), projectFlockKandangIDs) if err != nil { data.TotalEggWeightKg = 0 } diff --git a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go index d97424fa..95848839 100644 --- a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go +++ b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go @@ -2,6 +2,7 @@ package controller import ( "math" + "mime/multipart" "strconv" "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto" @@ -362,6 +363,9 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") } req.Documents = form.File["documents"] + if err := validateDailyChecklistDocumentSizes(req.Documents); err != nil { + return err + } if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -381,6 +385,16 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { }) } +func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error { + const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB + for _, file := range files { + if file != nil && file.Size > maxDailyChecklistDocumentBytes { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB") + } + } + return nil +} + func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error { param := c.Params("idDailyChecklist") diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index e2974039..6913f587 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -3,6 +3,7 @@ package service import ( "errors" "math" + "regexp" "sort" "strconv" "strings" @@ -259,8 +260,9 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ } if params.Search != "" { - like := "%" + params.Search + "%" - db = db.Where("(k.name ILIKE ? OR dc.category::text ILIKE ?)", like, like) + re := regexp.MustCompile("[^a-zA-Z0-9]") + like := re.ReplaceAll([]byte("%"+params.Search+"%"), []byte("")) + db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", string(like), string(like)) } countDB := db.Session(&gorm.Session{}) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 0cad4ff4..ceefcb1e 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -169,15 +169,30 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e CreatedBy: actorID, } + stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(ctx, productWarehouse.Id, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + newLog.Stock = latestStockLog.Stock + } else { + newLog.Stock = 0 + } + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity newLog.Increase = req.Quantity + newLog.Stock += newLog.Increase } else { if productWarehouse.Quantity < req.Quantity { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity)) } afterQuantity -= req.Quantity newLog.Decrease = req.Quantity + newLog.Stock -= newLog.Decrease } if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index e74332bc..aa5a6069 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -232,11 +232,24 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } for _, delivery := range req.Deliveries { - // Skip supplier validation if SupplierID is 0 (optional) + if delivery.SupplierID == 0 { continue } + if delivery.VehiclePlate == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih") + } + if delivery.DriverName == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Driver name wajib diisi ketika supplier dipilih") + } + if delivery.DeliveryCost <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost harus lebih dari 0 ketika supplier dipilih") + } + if delivery.DeliveryCostPerItem <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost per item harus lebih dari 0 ketika supplier dipilih") + } + supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -463,6 +476,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques LoggableId: uint(detail.Id), Notes: "", } + stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogDecrease.Stock -= latestStockLog.Stock - stockLogDecrease.Decrease + } else { + stockLogDecrease.Stock -= stockLogDecrease.Decrease + } + if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") } @@ -499,6 +524,17 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques LoggableId: uint(detail.Id), Notes: "", } + stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase + } else { + stockLogIncrease.Stock += stockLogIncrease.Increase + } if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") } diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go index e2f357f3..89676a41 100644 --- a/internal/modules/inventory/transfers/validations/transfer.validation.go +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -21,11 +21,11 @@ type TransferDeliveryProduct struct { } type TransferDelivery struct { - DeliveryCost float64 `json:"delivery_cost" validate:"required"` - DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` + DeliveryCost float64 `json:"delivery_cost"` + DeliveryCostPerItem float64 `json:"delivery_cost_per_item"` DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"` - DriverName string `json:"driver_name" validate:"required"` - VehiclePlate string `json:"vehicle_plate" validate:"required"` + DriverName string `json:"driver_name"` + VehiclePlate string `json:"vehicle_plate"` SupplierID uint `json:"supplier_id" ` Products []TransferDeliveryProduct `json:"products" validate:"required,dive"` } diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 231c00d4..17394b80 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -22,6 +22,7 @@ type MarketingDeliveryProductRepository interface { UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error GetUsageQty(ctx context.Context, id uint) (float64, error) ResetFifoFields(ctx context.Context, id uint) error + GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) } type MarketingDeliveryProductRepositoryImpl struct { @@ -93,6 +94,46 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context return deliveryProducts, nil } +func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Where("flags.name IN (?)", []string{ + string(utils.FlagAyamAfkir), + string(utils.FlagAyamCulling), + string(utils.FlagPullet), + string(utils.FlagLayer), + }). + Where("marketing_delivery_products.delivery_date IS NOT NULL"). + Distinct("marketing_delivery_products.*") + + if projectFlockKandangID != nil { + db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID) + } + + db = db. + Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product"). + Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). + Preload("MarketingProduct.ProductWarehouse.Product.Flags"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). + Order("marketing_delivery_products.delivery_date DESC") + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) { var deliveryProducts []entity.MarketingDeliveryProduct diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 2022cc78..51e37465 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -410,6 +410,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product") } + oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty var itemDeliveryDate *time.Time if requestedProduct.DeliveryDate != "" { parsedDate, err := utils.ParseDateString(requestedProduct.DeliveryDate) @@ -421,11 +422,8 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO itemDeliveryDate = deliveryProduct.DeliveryDate } - oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty - - // Cek apakah product punya flag PAKAN atau OVK isPakanOrOVK := false - if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { + if foundMarketingProduct.ProductWarehouse.Id != 0 && foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { isPakanOrOVK = true @@ -506,60 +504,82 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - if err == nil && result.UsageQuantity > 0 { - if actorID > 0 { - decreaseLog := &entity.StockLog{ - Decrease: result.UsageQuantity, - LoggableType: string(utils.StockLogTypeMarketing), - LoggableId: deliveryProduct.Id, - ProductWarehouseId: marketingProduct.ProductWarehouseId, - CreatedBy: actorID, - Notes: "", - } - s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil) - } + totalConsumed := 0.0 + var fifoConsumed float64 + var directConsumed float64 + + if result != nil && result.UsageQuantity > 0 { + fifoConsumed = result.UsageQuantity + totalConsumed = result.UsageQuantity } - if err != nil { + if err != nil || (totalConsumed < requestedQty) { + remainder := requestedQty - totalConsumed + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) if err2 != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") } - if pw == nil || pw.Quantity < requestedQty { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { + if pw == nil || pw.Quantity < remainder { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. FIFO: %.2f, Direct Available: %.2f, Total Needed: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } - }(), requestedQty)) + }(), remainder, requestedQty)) } - if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + if err := pwRepo.AdjustQuantities(ctx, map[uint]float64{ + marketingProduct.ProductWarehouseId: -remainder, + }, func(db *gorm.DB) *gorm.DB { + return tx + }); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to adjust product warehouse quantity") } - if actorID > 0 { - decreaseLog := &entity.StockLog{ - Decrease: requestedQty, - LoggableType: string(utils.StockLogTypeMarketing), - LoggableId: deliveryProduct.Id, - ProductWarehouseId: marketingProduct.ProductWarehouseId, - CreatedBy: actorID, - Notes: "", - } - s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil) - } - - return nil + directConsumed = remainder + totalConsumed += remainder } - if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, totalConsumed, 0); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } + if actorID > 0 && totalConsumed > 0 { + notes := "" + if fifoConsumed > 0 && directConsumed > 0 { + notes = fmt.Sprintf("Partial FIFO (%.2f) + Direct (%.2f)", fifoConsumed, directConsumed) + } else if fifoConsumed > 0 { + notes = fmt.Sprintf("FIFO stock only (%.2f)", fifoConsumed) + } else if directConsumed > 0 { + notes = fmt.Sprintf("Direct stock only (%.2f)", directConsumed) + } + + decreaseLog := &entity.StockLog{ + Decrease: totalConsumed, + LoggableType: string(utils.StockLogTypeMarketing), + LoggableId: deliveryProduct.Id, + ProductWarehouseId: marketingProduct.ProductWarehouseId, + CreatedBy: actorID, + Notes: notes, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + decreaseLog.Stock = latestStockLog.Stock + decreaseLog.Stock -= decreaseLog.Decrease + } else { + decreaseLog.Stock -= decreaseLog.Decrease + } + s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil) + } + return nil } @@ -599,6 +619,18 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor CreatedBy: actorID, Notes: "", } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + increaseLog.Stock = latestStockLog.Stock + increaseLog.Stock += increaseLog.Increase + } else { + increaseLog.Stock += increaseLog.Increase + } + s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil) } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index b39dca78..971ee072 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -645,6 +645,18 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, CreatedBy: actorID, Notes: fmt.Sprintf("Chickin #%d", chickin.Id), } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + decreaseLog.Stock = latestStockLog.Stock + decreaseLog.Stock -= decreaseLog.Decrease + } else { + decreaseLog.Stock -= decreaseLog.Decrease + } + s.StockLogRepo.CreateOne(ctx, decreaseLog, nil) } @@ -701,6 +713,18 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, CreatedBy: actorID, Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + increaseLog.Stock = latestStockLog.Stock + increaseLog.Stock += increaseLog.Increase + } else { + increaseLog.Stock += increaseLog.Increase + } + s.StockLogRepo.CreateOne(ctx, increaseLog, nil) } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 61f96e81..7a63d5da 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -4,6 +4,10 @@ import ( "context" "errors" "fmt" + "math" + "strings" + "time" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -19,9 +23,6 @@ import ( approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" - "math" - "strings" - "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -535,6 +536,17 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { continue } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + latestStockLog := &entity.StockLog{} + if len(stockLogs) > 0 { + latestStockLog = stockLogs[0] + } else { + latestStockLog.Stock = 0 + } logs = append(logs, &entity.StockLog{ ProductWarehouseId: egg.ProductWarehouseId, CreatedBy: actorID, @@ -542,6 +554,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin LoggableType: string(utils.StockLogTypeRecording), LoggableId: recordingEntity.Id, Notes: note, + Stock: latestStockLog.Stock - float64(egg.Qty), }) } if len(logs) > 0 { @@ -937,6 +950,18 @@ func (s *recordingService) consumeRecordingStocks( LoggableId: stock.RecordingId, Notes: note, } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock -= log.Decrease + } else { + log.Stock -= log.Decrease + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { return err } @@ -1004,6 +1029,18 @@ func (s *recordingService) consumeRecordingDepletions( LoggableId: depletion.RecordingId, Notes: note, } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock -= log.Decrease + } else { + log.Stock -= log.Decrease + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { return err } @@ -1022,6 +1059,18 @@ func (s *recordingService) consumeRecordingDepletions( LoggableId: depletion.RecordingId, Notes: note, } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { return err } @@ -1082,6 +1131,18 @@ func (s *recordingService) releaseRecordingStocks( LoggableId: stock.RecordingId, Notes: note, } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { return err } @@ -1144,6 +1205,18 @@ func (s *recordingService) releaseRecordingDepletions( LoggableId: depletion.RecordingId, Notes: note, } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { return err } @@ -1162,6 +1235,18 @@ func (s *recordingService) releaseRecordingDepletions( LoggableId: depletion.RecordingId, Notes: note, } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock -= log.Decrease + } else { + log.Stock -= log.Decrease + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { return err } @@ -1309,6 +1394,18 @@ func (s *recordingService) replenishRecordingEggs( LoggableId: egg.RecordingId, Notes: note, } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { return err } @@ -1692,7 +1789,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var eggMass float64 if remainingChick > 0 && totalEggWeightGrams > 0 { - eggMass = (totalEggWeightGrams / remainingChick) / 1000 + // totalEggWeightGrams is in grams; egg mass is grams per hen. + eggMass = totalEggWeightGrams / remainingChick updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else { diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index ebf63252..b3d7e7bc 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "strings" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" @@ -16,6 +17,10 @@ type TransferLayingRepository interface { // Tambah method baru untuk query dengan filter lengkap GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) + + // Get sequence for movement number + GetNextMovementNumber(ctx context.Context) (int64, error) + GenerateMovementNumber(ctx context.Context) (string, error) } type TransferLayingRepositoryImpl struct { @@ -29,6 +34,26 @@ func NewTransferLayingRepository(db *gorm.DB) TransferLayingRepository { db: db, } } + +func (r *TransferLayingRepositoryImpl) GetNextMovementNumber(ctx context.Context) (int64, error) { + var seq int64 + err := r.db.WithContext(ctx).Raw("SELECT nextval('transfer_laying_seq')").Scan(&seq).Error + if err != nil { + return 0, err + } + return seq, nil +} + +func (r *TransferLayingRepositoryImpl) GenerateMovementNumber(ctx context.Context) (string, error) { + seq, err := r.GetNextMovementNumber(ctx) + if err != nil { + return "", err + } + // Format: TL00001, TL00002, dst + movementNumber := fmt.Sprintf("TL%05d", seq) + return movementNumber, nil +} + func (r *TransferLayingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { return repository.Exists[entity.LayingTransfer](ctx, r.db, id) } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 310391c6..e6e9a862 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -271,7 +271,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) } - transferNumber := fmt.Sprintf("TL-%d", time.Now().UnixNano()) + transferNumber, err := s.Repository.GenerateMovementNumber(c.Context()) + if err != nil { + s.Log.Errorf("Failed to generate movement number: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer") + } createBody := &entity.LayingTransfer{ TransferNumber: transferNumber, @@ -440,15 +444,105 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return nil, fiber.NewError(fiber.StatusBadRequest, "Target project flock not found") } + sourceKandangIDs := make([]uint, len(req.SourceKandangs)) + for i, detail := range req.SourceKandangs { + sourceKandangIDs[i] = detail.ProjectFlockKandangId + } + + if err := s.validateKandangOwnership( + c.Context(), + req.SourceProjectFlockId, + sourceKandangIDs, + ); err != nil { + return nil, err + } + + targetKandangIDs := make([]uint, len(req.TargetKandangs)) + for i, detail := range req.TargetKandangs { + targetKandangIDs[i] = detail.ProjectFlockKandangId + } + + if err := s.validateKandangOwnership( + c.Context(), + req.TargetProjectFlockId, + targetKandangIDs, + ); err != nil { + return nil, err + } + transferDate, err := time.Parse("2006-01-02", req.TransferDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format") } + var totalSourceQty, totalTargetQty float64 + sourceWarehouseMap := make(map[uint]uint) + + for _, sourceDetail := range req.SourceKandangs { + if sourceDetail.Quantity <= 0 { + continue + } + totalSourceQty += sourceDetail.Quantity + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) + if err != nil { + return nil, err + } + + var totalPopulation float64 + var productWarehouseId uint + for _, pop := range populations { + totalPopulation += pop.TotalQty + if productWarehouseId == 0 { + productWarehouseId = pop.ProductWarehouseId + } + } + + if totalPopulation == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki populasi untuk ditransfer", sourceDetail.ProjectFlockKandangId)) + } + + if totalPopulation < sourceDetail.Quantity { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d jumlah tidak mencukupi. Tersedia: %.0f, Diminta: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) + } + + sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId + } + + for _, targetDetail := range req.TargetKandangs { + if targetDetail.Quantity <= 0 { + continue + } + totalTargetQty += targetDetail.Quantity + } + + if totalSourceQty == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang sumber dengan jumlah lebih dari 0") + } + if totalTargetQty == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang tujuan dengan jumlah lebih dari 0") + } + + if totalSourceQty != totalTargetQty { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) + } + + // Ambil productWarehouseId pertama dari source yang valid (quantity > 0) + var firstProductWarehouseId uint + for _, sourceDetail := range req.SourceKandangs { + if sourceDetail.Quantity > 0 { + if pwId, ok := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]; ok { + firstProductWarehouseId = pwId + break + } + } + } + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) sourceRepo := s.LayingTransferSourceRepo.WithTx(dbTransaction) targetRepo := s.LayingTransferTargetRepo.WithTx(dbTransaction) + pwRepo := rInventory.NewProductWarehouseRepository(dbTransaction) // Hapus old sources dan targets for _, oldSource := range existingTransfer.Sources { @@ -472,26 +566,11 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, // Create new sources dengan pending quantity for _, sourceDetail := range req.SourceKandangs { - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get populations") + if sourceDetail.Quantity == 0 { + continue } - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no population available", sourceDetail.ProjectFlockKandangId)) - } - - var productWarehouseId uint - for _, pop := range populations { - if pop.ProductWarehouseId > 0 { - productWarehouseId = pop.ProductWarehouseId - break - } - } - - if productWarehouseId == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no product warehouse", sourceDetail.ProjectFlockKandangId)) - } + productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] source := entity.LayingTransferSource{ LayingTransferId: id, @@ -506,7 +585,18 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, } } - pwRepo := rInventory.NewProductWarehouseRepository(dbTransaction) + // Ambil product ID dari source warehouse pertama yang valid + var sourceProductID uint + if firstProductWarehouseId > 0 { + sourcePW, err := pwRepo.GetByID(c.Context(), firstProductWarehouseId, nil) + if err == nil { + sourceProductID = sourcePW.ProductId + } + } + + if sourceProductID == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse") + } for _, targetDetail := range req.TargetKandangs { targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) @@ -522,23 +612,6 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") } - // Ambil product ID dari source yang pertama (semua sources seharusnya product-nya sama) - var sourceProductID uint - if len(req.SourceKandangs) > 0 { - firstSourceKandangID := req.SourceKandangs[0].ProjectFlockKandangId - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), firstSourceKandangID) - if err == nil && len(populations) > 0 && populations[0].ProductWarehouseId > 0 { - sourcePW, err := pwRepo.GetByID(c.Context(), populations[0].ProductWarehouseId, nil) - if err == nil { - sourceProductID = sourcePW.ProductId - } - } - } - - if sourceProductID == 0 { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse") - } - targetPW, err := pwRepo.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -753,6 +826,18 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( LoggableId: approvableID, Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), } + stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *source.ProductWarehouseId, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease + } else { + stockLogDecrease.Stock -= stockLogDecrease.Decrease + } + if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") } @@ -791,6 +876,18 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( LoggableId: approvableID, Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), } + stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *target.ProductWarehouseId, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase + } else { + stockLogIncrease.Stock += stockLogIncrease.Increase + } + if err := stockLogRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b789ef34..dabfad39 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -1081,10 +1081,25 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation LoggableId: purchase.Id, Notes: receiveNote, } + stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, entry.pwID, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + } else { + log.Stock = 0 + } + if entry.delta > 0 { log.Increase = entry.delta + log.Stock += log.Increase } else { log.Decrease = -entry.delta + log.Stock -= log.Decrease } logs = append(logs, log) } diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index b49d73e5..5e75d4a9 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -200,7 +200,7 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") } - issueCookies(c, struct { + if err := issueCookies(c, struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` @@ -218,7 +218,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { IDToken: tokenResp.IDToken, Error: tokenResp.Error, Description: tokenResp.Description, - }, verification) + }, verification); err != nil { + return err + } utils.Log.WithFields(logrus.Fields{ "user_id": verification.UserID, @@ -307,7 +309,9 @@ func (h *Controller) Callback(c *fiber.Ctx) error { } // prepare cookies - issueCookies(c, tokenResp, verification) + if err := issueCookies(c, tokenResp, verification); err != nil { + return err + } redirectTarget := sessionData.ReturnTo if redirectTarget == "" { @@ -742,13 +746,21 @@ func issueCookies(c *fiber.Ctx, tokenResp struct { IDToken string `json:"id_token"` Error string `json:"error"` Description string `json:"error_description"` -}, verification *sso.VerificationResult) { +}, verification *sso.VerificationResult) error { if revoker := session.GetRevocationStore(); revoker != nil && verification != nil { if err := revoker.ClearUserLogout(c.Context(), verification.UserID); err != nil { utils.Log.WithError(err).Warn("failed to clear logout marker") } } + if max := config.SSOAccessTokenMaxBytes; max > 0 && len(tokenResp.AccessToken) > max { + utils.Log.WithFields(logrus.Fields{ + "token_len": len(tokenResp.AccessToken), + "max_len": max, + }).Warn("sso access token exceeds cookie size limit") + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "access token too large") + } + accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") maxAge := tokenResp.ExpiresIn @@ -790,6 +802,7 @@ func issueCookies(c *fiber.Ctx, tokenResp struct { // Optional: expose limited info via headers for FE debugging (avoid tokens) c.Set("X-Auth-User", fmt.Sprintf("%d", verification.UserID)) + return nil } func clearSSOCookie(c *fiber.Ctx, name string) { diff --git a/internal/modules/sso/controllers/user_sync.controller.go b/internal/modules/sso/controllers/user_sync.controller.go index 72c7768a..bdc7900e 100644 --- a/internal/modules/sso/controllers/user_sync.controller.go +++ b/internal/modules/sso/controllers/user_sync.controller.go @@ -291,6 +291,8 @@ func (h *UserSyncController) upsertUser(c *fiber.Ctx, alias string, req *userSyn "user_id": req.User.ID, }).Info("sso user synced") + sso.InvalidateProfileCache(c.Context(), uint(req.User.ID)) + msg := fmt.Sprintf("User %s successfully", req.Action) return c.Status(fiber.StatusOK).JSON(response.Success{ Code: fiber.StatusOK, @@ -318,6 +320,8 @@ func (h *UserSyncController) logoutUser(c *fiber.Ctx, alias string, req *userSyn "user_id": req.User.ID, }).Info("sso user logout enforced") + sso.InvalidateProfileCache(c.Context(), uint(req.User.ID)) + return c.Status(fiber.StatusOK).JSON(response.Common{ Code: fiber.StatusOK, Status: "success", @@ -341,6 +345,8 @@ func (h *UserSyncController) removeUser(c *fiber.Ctx, alias string, req *userSyn "user_id": req.User.ID, }).Info("sso user deleted") + sso.InvalidateProfileCache(c.Context(), uint(req.User.ID)) + return c.Status(fiber.StatusOK).JSON(response.Common{ Code: fiber.StatusOK, Status: "success", diff --git a/internal/modules/sso/verifier/profile.go b/internal/modules/sso/verifier/profile.go index e3cd40ca..4876db1e 100644 --- a/internal/modules/sso/verifier/profile.go +++ b/internal/modules/sso/verifier/profile.go @@ -265,24 +265,44 @@ func profileCacheKey(userID uint) string { return profileCachePrefix + strconv.FormatUint(uint64(userID), 10) } +// InvalidateProfileCache clears cached profile data for the given user in both local and Redis caches. +func InvalidateProfileCache(ctx context.Context, userID uint) { + if userID == 0 { + return + } + key := profileCacheKey(userID) + profileLocalCache.Delete(key) + + client := cache.Redis() + if client == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + if err := client.Del(ctx, key).Err(); err != nil && !errors.Is(err, redis.Nil) { + utils.Log.WithError(err).Warn("sso profile redis delete failed") + } +} + func canonicalPermissionName(name string) string { return strings.ToLower(strings.TrimSpace(name)) } // userInfoEnvelope handles the varying shapes returned by the SSO userinfo endpoint. type userInfoEnvelope struct { - Roles []userInfoRole `json:"roles"` - AreaIDs []uint `json:"area_ids"` - LocationIDs []uint `json:"location_ids"` - AllArea bool `json:"all_area"` - AllLocation bool `json:"all_location"` - Data *struct { - ID int64 `json:"id"` - Roles []userInfoRole `json:"roles"` - AreaIDs []uint `json:"area_ids"` - LocationIDs []uint `json:"location_ids"` - AllArea bool `json:"all_area"` - AllLocation bool `json:"all_location"` + Roles []userInfoRole `json:"roles"` + AreaIDs []uint `json:"area_ids"` + LocationIDs []uint `json:"location_ids"` + AllArea bool `json:"all_area"` + AllLocation bool `json:"all_location"` + Data *struct { + ID int64 `json:"id"` + Roles []userInfoRole `json:"roles"` + AreaIDs []uint `json:"area_ids"` + LocationIDs []uint `json:"location_ids"` + AllArea bool `json:"all_area"` + AllLocation bool `json:"all_location"` } `json:"data"` User *struct { ID int64 `json:"id"`