package repositories import ( "context" "errors" "fmt" "strconv" "strings" "time" "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 uint64, items []*entity.PurchaseItem) error GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) UpdatePricing(ctx context.Context, purchaseID uint64, updates []PurchasePricingUpdate, grandTotal float64) error UpdateReceivingDetails(ctx context.Context, purchaseID uint64, updates []PurchaseReceivingUpdate) error DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) error WithListRelations() func(*gorm.DB) *gorm.DB UpdateGrandTotal(ctx context.Context, purchaseID uint64, grandTotal float64) error NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) } type PurchaseRepositoryImpl struct { *repository.BaseRepositoryImpl[entity.Purchase] } func NewPurchaseRepository(db *gorm.DB) PurchaseRepository { return &PurchaseRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.Purchase](db), } } type PurchaseListFilter struct { SupplierID uint Search string PrNumber string CreatedFrom *time.Time CreatedTo *time.Time Status *entity.ApprovalAction CompletedOnly bool } func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error { db := r.DB().WithContext(ctx) 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) CreateItems(ctx context.Context, purchaseID uint64, 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) GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) { var purchase entity.Purchase err := r.DB().WithContext(ctx). Scopes(r.withDetailRelations). First(&purchase, id).Error if err != nil { return nil, err } return &purchase, nil } func (r *PurchaseRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) { return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { db = r.withListRelations(db) return r.applyListFilters(db, filter) }) } func (r *PurchaseRepositoryImpl) WithListRelations() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return r.withListRelations(db) } } func (r *PurchaseRepositoryImpl) withDetailRelations(db *gorm.DB) *gorm.DB { return db. Preload("Supplier"). Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("id ASC") }). Preload("Items.Product"). Preload("Items.Warehouse"). Preload("Items.Warehouse.Area"). Preload("Items.Warehouse.Location"). Preload("Items.ProductWarehouse") } func (r *PurchaseRepositoryImpl) WithDetailRelations() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return r.withDetailRelations(db) } } type PurchasePricingUpdate struct { ItemID uint64 ProductID *uint64 Price float64 TotalPrice float64 Quantity *float64 TotalQty *float64 } type PurchaseReceivingUpdate struct { ItemID uint64 ReceivedDate *time.Time TravelNumber *string TravelDocumentPath *string VehicleNumber *string ReceivedQty *float64 WarehouseID *uint ProductWarehouseID *uint ClearProductWarehouse bool } func (r *PurchaseRepositoryImpl) UpdatePricing( ctx context.Context, purchaseID uint64, updates []PurchasePricingUpdate, grandTotal float64, ) 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 { return gorm.ErrRecordNotFound } } if err := db.Model(&entity.Purchase{}). Where("id = ?", purchaseID). Updates(map[string]interface{}{ "grand_total": grandTotal, "updated_at": gorm.Expr("NOW()"), }).Error; err != nil { return err } return nil } func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( ctx context.Context, purchaseID uint64, 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 } if upd.ReceivedQty != nil { data["total_qty"] = upd.ReceivedQty } 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 { return gorm.ErrRecordNotFound } } return nil } func (r *PurchaseRepositoryImpl) UpdateGrandTotal( ctx context.Context, purchaseID uint64, grandTotal float64, ) error { return r.DB().WithContext(ctx). Model(&entity.Purchase{}). Where("id = ?", purchaseID). Updates(map[string]interface{}{ "grand_total": grandTotal, "updated_at": gorm.Expr("NOW()"), }).Error } func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) 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 LIKE ?", 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 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 } func (r *PurchaseRepositoryImpl) withListRelations(db *gorm.DB) *gorm.DB { return db.Preload("Supplier") } func (r *PurchaseRepositoryImpl) applyListFilters(db *gorm.DB, filter *PurchaseListFilter) *gorm.DB { if filter == nil { return db } if filter.SupplierID > 0 { db = db.Where("purchases.supplier_id = ?", filter.SupplierID) } if search := strings.ToLower(strings.TrimSpace(filter.Search)); search != "" { like := "%" + search + "%" db = db.Where("(LOWER(purchases.pr_number) LIKE ? OR LOWER(COALESCE(purchases.notes, '')) LIKE ?)", like, like) } if pr := strings.TrimSpace(filter.PrNumber); pr != "" { db = db.Where("purchases.pr_number ILIKE ?", "%"+pr+"%") } if filter.CreatedFrom != nil { db = db.Where("purchases.created_at >= ?", *filter.CreatedFrom) } if filter.CreatedTo != nil { db = db.Where("purchases.created_at < ?", *filter.CreatedTo) } if filter.CompletedOnly { step := uint16(utils.PurchaseStepCompleted) db = r.applyLatestApprovalFilter(db, entity.ApprovalActionApproved, &step) } else if filter.Status != nil { db = r.applyLatestApprovalFilter(db, *filter.Status, nil) } return db.Order("purchases.created_at DESC").Order("purchases.id DESC") } func (r *PurchaseRepositoryImpl) applyLatestApprovalFilter(db *gorm.DB, action entity.ApprovalAction, minStep *uint16) *gorm.DB { latestSub := r.DB(). Model(&entity.Approval{}). Select("approvable_id, MAX(action_at) AS latest_action_at"). Where("approvable_type = ?", utils.ApprovalWorkflowPurchase.String()). Group("approvable_id") db = db. Joins("LEFT JOIN (?) AS latest_purchase_approvals ON latest_purchase_approvals.approvable_id = purchases.id", latestSub). Joins( "LEFT JOIN approvals ON approvals.approvable_id = purchases.id AND approvals.approvable_type = ? AND approvals.action_at = latest_purchase_approvals.latest_action_at", utils.ApprovalWorkflowPurchase.String(), ). Where("approvals.action = ?", string(action)) if minStep != nil { db = db.Where("approvals.step_number >= ?", *minStep) } return db }