package repositories import ( "context" "errors" "fmt" "strconv" "strings" "time" "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) type PurchaseRepository interface { repository.BaseRepository[entity.Purchase] CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate) error UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) } type PurchaseRepositoryImpl struct { *repository.BaseRepositoryImpl[entity.Purchase] } func NewPurchaseRepository(db *gorm.DB) PurchaseRepository { return &PurchaseRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.Purchase](db), } } func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error { db := r.DB().WithContext(ctx) //ambil dari base repository if err := db.Create(purchase).Error; err != nil { return err } if len(items) == 0 { return nil } for _, item := range items { item.PurchaseId = purchase.Id } if err := db.Create(&items).Error; err != nil { return err } return nil } func (r *PurchaseRepositoryImpl) BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error { if purchaseID == 0 { return nil } query := ` WITH latest_pfk AS ( SELECT pfk.id, pfk.kandang_id FROM project_flock_kandangs pfk JOIN ( SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at FROM approvals WHERE approvable_type = 'PROJECT_FLOCKS' ORDER BY approvable_id, action_at DESC ) latest_approval ON latest_approval.approvable_id = pfk.project_flock_id WHERE LOWER(latest_approval.step_name) = LOWER('Aktif') ) UPDATE purchase_items pi SET project_flock_kandang_id = lp.id FROM warehouses w JOIN latest_pfk lp ON lp.kandang_id = w.kandang_id WHERE pi.purchase_id = ? AND pi.project_flock_kandang_id IS NULL AND pi.warehouse_id = w.id; ` return r.DB().WithContext(ctx).Exec(query, purchaseID).Error } func (r *PurchaseRepositoryImpl) SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { if len(projectFlockKandangIDs) == 0 { return nil } return r.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var purchaseIDs []uint query := ` SELECT pi.purchase_id FROM purchase_items pi WHERE pi.project_flock_kandang_id IN (?) GROUP BY pi.purchase_id HAVING COUNT(*) = COUNT(CASE WHEN pi.project_flock_kandang_id IN (?) THEN 1 END) ` if err := tx.Raw(query, projectFlockKandangIDs, projectFlockKandangIDs).Scan(&purchaseIDs).Error; err != nil { return err } now := time.Now().UTC() if len(purchaseIDs) > 0 { if err := tx.Model(&entity.Purchase{}). Where("id IN (?) AND deleted_at IS NULL", purchaseIDs). Update("deleted_at", now).Error; err != nil { return err } if err := tx.Where("purchase_id IN (?)", purchaseIDs).Delete(&entity.PurchaseItem{}).Error; err != nil { return err } } deleteItems := tx.Where("project_flock_kandang_id IN (?)", projectFlockKandangIDs) if len(purchaseIDs) > 0 { deleteItems = deleteItems.Where("purchase_id NOT IN (?)", purchaseIDs) } return deleteItems.Delete(&entity.PurchaseItem{}).Error }) } func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error { if len(items) == 0 { return nil } for _, item := range items { if item == nil { continue } item.PurchaseId = purchaseID } return r.DB().WithContext(ctx).Create(&items).Error } func (r *PurchaseRepositoryImpl) purchaseItemExists(ctx context.Context, purchaseID uint, itemID uint) (bool, error) { var count int64 if err := r.DB().WithContext(ctx). Model(&entity.PurchaseItem{}). Where("purchase_id = ? AND id = ?", purchaseID, itemID). Count(&count).Error; err != nil { return false, err } return count > 0, nil } type PurchasePricingUpdate struct { ItemID uint ProductID *uint Price float64 TotalPrice float64 Quantity *float64 TotalQty *float64 } type PurchaseReceivingUpdate struct { ItemID uint ReceivedDate *time.Time TravelNumber *string TravelDocumentPath *string VehicleNumber *string ClearVehicleNumber bool ReceivedQty *float64 WarehouseID *uint ProductWarehouseID *uint ClearProductWarehouse bool } func (r *PurchaseRepositoryImpl) UpdatePricing( ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate, ) error { if len(updates) == 0 { return errors.New("pricing updates cannot be empty") } db := r.DB().WithContext(ctx) for _, upd := range updates { data := map[string]interface{}{ "price": upd.Price, "total_price": upd.TotalPrice, } if upd.ProductID != nil { data["product_id"] = *upd.ProductID } if upd.Quantity != nil { data["sub_qty"] = *upd.Quantity } if upd.TotalQty != nil { data["total_qty"] = *upd.TotalQty } result := db.Model(&entity.PurchaseItem{}). Where("purchase_id = ? AND id = ?", purchaseID, upd.ItemID). Updates(data) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID) if err != nil { return err } if !exists { return gorm.ErrRecordNotFound } } } return nil } func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate, ) error { if len(updates) == 0 { return errors.New("receiving updates cannot be empty") } db := r.DB().WithContext(ctx) for _, upd := range updates { data := map[string]interface{}{} if upd.ReceivedDate != nil { data["received_date"] = upd.ReceivedDate } if upd.TravelNumber != nil { data["travel_number"] = upd.TravelNumber } if upd.TravelDocumentPath != nil { data["travel_number_docs"] = upd.TravelDocumentPath } if upd.VehicleNumber != nil { data["vehicle_number"] = upd.VehicleNumber } else if upd.ClearVehicleNumber { data["vehicle_number"] = "" } if upd.WarehouseID != nil && *upd.WarehouseID != 0 { data["warehouse_id"] = upd.WarehouseID } if upd.ProductWarehouseID != nil { data["product_warehouse_id"] = *upd.ProductWarehouseID } else if upd.ClearProductWarehouse { data["product_warehouse_id"] = gorm.Expr("NULL") } if len(data) == 0 { continue } result := db.Model(&entity.PurchaseItem{}). Where("purchase_id = ? AND id = ?", purchaseID, upd.ItemID). Updates(data) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID) if err != nil { return err } if !exists { return gorm.ErrRecordNotFound } } } return nil } func (r *PurchaseRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) { const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'" subQuery := r.DB().WithContext(ctx). Table("purchases AS p"). Select(` DISTINCT p.id AS purchase_id, 'Purchases' AS module, COALESCE(pf_explicit.flock_name, pf_active.flock_name, kandang_loc.name, warehouse_loc.name, 'Unknown Farm') AS farm_name, COALESCE(k_explicit.name, k_active.name, wk.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name, DATE(p.po_date) AS activity_date `). Joins("JOIN purchase_items pi ON pi.purchase_id = p.id"). Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). Joins("LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id"). Joins("LEFT JOIN project_flocks pf_explicit ON pf_explicit.id = pfk_explicit.project_flock_id"). Joins("LEFT JOIN kandangs k_explicit ON k_explicit.id = pfk_explicit.kandang_id"). Joins("LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL"). Joins("LEFT JOIN project_flocks pf_active ON pf_active.id = pfk_active.project_flock_id"). Joins("LEFT JOIN kandangs k_active ON k_active.id = pfk_active.kandang_id"). Joins("LEFT JOIN kandangs wk ON wk.id = w.kandang_id"). Joins("LEFT JOIN locations kandang_loc ON kandang_loc.id = COALESCE(k_explicit.location_id, k_active.location_id, wk.location_id)"). Joins("LEFT JOIN locations warehouse_loc ON warehouse_loc.id = w.location_id"). Where("p.deleted_at IS NULL"). Where("p.po_date IS NOT NULL"). Where("DATE(p.po_date) >= DATE(?)", startDate). Where("DATE(p.po_date) <= DATE(?)", endDate) if restrict { if len(allowedLocationIDs) == 0 { return []exportprogress.Row{}, nil } subQuery = subQuery.Where("w.location_id IN ?", allowedLocationIDs) } type progressRowResult struct { Module string FarmName string KandangName string ActivityDate string Count int } scanned := make([]progressRowResult, 0) err := r.DB().WithContext(ctx). Table("(?) AS progress_rows", subQuery). Select("module, farm_name, kandang_name, activity_date, COUNT(*) AS count"). Group("module, farm_name, kandang_name, activity_date"). Order("activity_date ASC, farm_name ASC, kandang_name ASC"). Scan(&scanned).Error if err != nil { return nil, err } rows := make([]exportprogress.Row, 0, len(scanned)) for _, item := range scanned { activityDate, err := time.Parse("2006-01-02", item.ActivityDate) if err != nil { return nil, err } rows = append(rows, exportprogress.Row{ Module: item.Module, FarmName: item.FarmName, KandangName: item.KandangName, ActivityDate: activityDate, Count: item.Count, }) } return rows, nil } func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error { if len(itemIDs) == 0 { return errors.New("itemIDs cannot be empty") } return r.DB().WithContext(ctx). Where("purchase_id = ? AND id IN ?", purchaseID, itemIDs). Delete(&entity.PurchaseItem{}).Error } func (r *PurchaseRepositoryImpl) NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) { return r.generateSequentialNumber(ctx, tx, "pr_number", utils.PurchasePRNumberPrefix, utils.PurchaseNumberPadding) } func (r *PurchaseRepositoryImpl) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) { return r.generateSequentialNumber(ctx, tx, "po_number", utils.PurchasePONumberPrefix, utils.PurchaseNumberPadding) } func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) { db := tx if db == nil { db = r.DB() } var values []string err := db.WithContext(ctx). Model(&entity.Purchase{}). Where(fmt.Sprintf("%s ILIKE ?", column), prefix+"%"). Select(column). Order(fmt.Sprintf("%s DESC", column)). Limit(20). Clauses(clause.Locking{Strength: "UPDATE"}). Pluck(column, &values).Error if err != nil { return "", err } next := 1 for _, value := range values { if number, ok := parseNumericSuffix(value, prefix); ok { next = number + 1 break } } const maxAttempts = 20 for attempt := 0; attempt < maxAttempts; attempt++ { candidate := fmt.Sprintf("%s%0*d", prefix, padding, next) exists, err := r.numberExists(ctx, db, column, candidate) if err != nil { return "", err } if !exists { return candidate, nil } next++ } return "", fmt.Errorf("unable to generate unique %s", column) } func (r *PurchaseRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, column, value string) (bool, error) { var count int64 if err := db.WithContext(ctx). Model(&entity.Purchase{}). Where(fmt.Sprintf("%s = ?", column), value). Count(&count).Error; err != nil { return false, err } return count > 0, nil } func (r *PurchaseRepositoryImpl) GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) { return r.GetItemsByWarehouseKandang(ctx, projectFlockID) } func (r *PurchaseRepositoryImpl) GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) { var items []entity.PurchaseItem var kandangIDs []uint err := r.DB().WithContext(ctx). Table("project_flock_kandangs"). Where("project_flock_id = ?", projectFlockID). Pluck("kandang_id", &kandangIDs).Error if err != nil { return nil, err } if len(kandangIDs) == 0 { return []entity.PurchaseItem{}, nil } err = r.DB().WithContext(ctx). Preload("Product"). Preload("Product.Flags"). Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). Where("warehouses.kandang_id IN ?", kandangIDs). Find(&items).Error return items, err } func parseNumericSuffix(value, prefix string) (int, bool) { if !strings.HasPrefix(value, prefix) { return 0, false } suffix := strings.TrimPrefix(value, prefix) if suffix == "" { return 0, false } trimmed := strings.TrimLeft(suffix, "0") if trimmed == "" { trimmed = "0" } number, err := strconv.Atoi(trimmed) if err != nil { return 0, false } return number, true }