Files
lti-api/internal/modules/purchases/repositories/purchase.repository.go
T
2026-04-21 21:41:54 +07:00

480 lines
14 KiB
Go

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,
CAST(DATE(p.po_date) AS TEXT) 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 := exportprogress.ParseActivityDate(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
}