package service

import (
	"context"
	"errors"
	"fmt"
	"math"
	"mime/multipart"
	"sort"
	"strconv"
	"strings"
	"time"

	"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
	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"
	m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
	rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
	rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
	rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
	rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
	projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
	rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
	validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
	rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
	"gitlab.com/mbugroup/lti-api.git/internal/utils"
	approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
	"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"

	"github.com/go-playground/validator/v10"
	"github.com/gofiber/fiber/v2"
	"github.com/sirupsen/logrus"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

type PurchaseService interface {
	GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error)
	GetOne(ctx *fiber.Ctx, id uint) (*entity.Purchase, error)
	CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error)
	ApproveStaffPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error)
	ApproveManagerPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error)
	ReceiveProducts(ctx *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error)
	DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error)
	DeletePurchase(ctx *fiber.Ctx, id uint) error
	GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
}

const (
	priceTolerance = 0.0001
)

type purchaseService struct {
	Log                     *logrus.Logger
	Validate                *validator.Validate
	PurchaseRepo            rPurchase.PurchaseRepository
	ProductRepo             rProduct.ProductRepository
	WarehouseRepo           rWarehouse.WarehouseRepository
	SupplierRepo            rSupplier.SupplierRepository
	ProductWarehouseRepo    rProductWarehouse.ProductWarehouseRepository
	ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
	ApprovalSvc             commonSvc.ApprovalService
	ExpenseBridge           PurchaseExpenseBridge
	FifoStockV2Svc          commonSvc.FifoStockV2Service
	DocumentSvc             commonSvc.DocumentService
	approvalWorkflow        approvalutils.ApprovalWorkflowKey
}

type staffAdjustmentPayload struct {
	PricingUpdates []rPurchase.PurchasePricingUpdate
	NewItems       []*entity.PurchaseItem
}

type purchaseDownstreamDependency struct {
	UsableType    string `gorm:"column:usable_type"`
	UsableID      uint64 `gorm:"column:usable_id"`
	FunctionCode  string `gorm:"column:function_code"`
	FlagGroupCode string `gorm:"column:flag_group_code"`
}

func NewPurchaseService(
	validate *validator.Validate,
	purchaseRepo rPurchase.PurchaseRepository,
	productRepo rProduct.ProductRepository,
	warehouseRepo rWarehouse.WarehouseRepository,
	supplierRepo rSupplier.SupplierRepository,
	productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
	projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
	approvalSvc commonSvc.ApprovalService,
	expenseBridge PurchaseExpenseBridge,
	fifoStockV2Svc commonSvc.FifoStockV2Service,
	documentSvc commonSvc.DocumentService,
) PurchaseService {
	return &purchaseService{
		Log:                     utils.Log,
		Validate:                validate,
		PurchaseRepo:            purchaseRepo,
		ProductRepo:             productRepo,
		WarehouseRepo:           warehouseRepo,
		SupplierRepo:            supplierRepo,
		ProductWarehouseRepo:    productWarehouseRepo,
		ProjectFlockKandangRepo: projectFlockKandangRepo,
		ApprovalSvc:             approvalSvc,
		ExpenseBridge:           expenseBridge,
		FifoStockV2Svc:          fifoStockV2Svc,
		DocumentSvc:             documentSvc,
		approvalWorkflow:        utils.ApprovalWorkflowPurchase,
	}
}
func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB {
	if db == nil {
		return db
	}

	return db.
		Preload("Supplier").
		Preload("CreatedUser").
		Preload("Items", func(db *gorm.DB) *gorm.DB {
			return db.Order("id ASC")
		}).
		Preload("Items.Product").
		Preload("Items.Product.Uom").
		Preload("Items.Product.ProductCategory").
		Preload("Items.Warehouse").
		Preload("Items.Product.Flags").
		Preload("Items.Warehouse.Area").
		Preload("Items.Warehouse.Location").
		Preload("Items.ProductWarehouse").
		Preload("Items.ExpenseNonstock").
		Preload("Items.ExpenseNonstock.Expense").
		Preload("Items.ExpenseNonstock.Expense.Supplier")
}

func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) {
	if err := s.Validate.Struct(params); err != nil {
		return nil, 0, err
	}

	scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
	if err != nil {
		return nil, 0, err
	}

	offset := (params.Page - 1) * params.Limit

	createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo)
	if err != nil {
		return nil, 0, utils.BadRequest(err.Error())
	}

	productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id")
	if err != nil {
		return nil, 0, utils.BadRequest(err.Error())
	}

	var poDateStart *time.Time
	var poDateEnd *time.Time

	if strings.TrimSpace(params.PoDate) != "" {
		poDate, parseErr := utils.ParseDateString(strings.TrimSpace(params.PoDate))
		if parseErr != nil {
			return nil, 0, utils.BadRequest("po_date must use format YYYY-MM-DD")
		}
		poDateStart = &poDate
		poDateEndValue := poDate.AddDate(0, 0, 1)
		poDateEnd = &poDateEndValue
	} else {
		poDateStart, poDateEnd, err = parsePoDateRangeForQuery(params.PoDateFrom, params.PoDateTo)
		if err != nil {
			return nil, 0, utils.BadRequest(err.Error())
		}
	}

	search := strings.ToLower(strings.TrimSpace(params.Search))
	approvalStatuses := parseStringCSVFilter(params.ApprovalStatus)
	for i := range approvalStatuses {
		approvalStatuses[i] = normalizeApprovalStatusFilter(approvalStatuses[i])
	}

	purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
		db = s.withRelations(db)
		db = db.Where("purchases.deleted_at IS NULL")

		if params.SupplierID > 0 {
			db = db.Where("supplier_id = ?", params.SupplierID)
		}

		if createdFrom != nil {
			db = db.Where("created_at >= ?", *createdFrom)
		}

		if createdTo != nil {
			db = db.Where("created_at < ?", *createdTo)
		}
		if poDateStart != nil {
			db = db.Where("purchases.po_date >= ?", *poDateStart)
		}

		if poDateStart != nil {
			db = db.Where("purchases.po_date >= ?", *poDateStart)
		}

		if poDateEnd != nil {
			db = db.Where("purchases.po_date < ?", *poDateEnd)
		}

		if scope.Restrict {
			if len(scope.IDs) == 0 {
				return db.Where("1 = 0")
			}
			db = db.Where(
				`EXISTS (
					SELECT 1
					FROM purchase_items pi
					JOIN warehouses w ON w.id = pi.warehouse_id
					WHERE pi.purchase_id = purchases.id AND w.location_id IN ?
				)`,
				scope.IDs,
			)
		}

		if params.AreaID > 0 {
			db = db.Where(
				`EXISTS (
					SELECT 1
					FROM purchase_items pi
					JOIN warehouses w ON w.id = pi.warehouse_id
					WHERE pi.purchase_id = purchases.id AND w.area_id = ?
				)`,
				params.AreaID,
			)
		}

		if params.LocationID > 0 {
			db = db.Where(
				`EXISTS (
					SELECT 1
					FROM purchase_items pi
					JOIN warehouses w ON w.id = pi.warehouse_id
					WHERE pi.purchase_id = purchases.id AND w.location_id = ?
				)`,
				params.LocationID,
			)
		}

		if len(productCategoryIDs) > 0 {
			db = db.Where(
				`EXISTS (
						SELECT 1
					FROM purchase_items pi
					JOIN products p ON p.id = pi.product_id
								WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ?
				)`,
				productCategoryIDs,
			)
		}

		db = applyPurchaseProjectFlockFilter(db, params.ProjectFlockID, params.ProjectFlockKandangID)
		db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
		db = applyPurchaseSearchFilter(db, search)

		return db.Order("created_at DESC").Order("purchases.id DESC")
	})

	if err != nil {
		s.Log.Errorf("Failed to get purchases: %+v", err)
		return nil, 0, utils.Internal("Failed to get purchases")
	}

	if err := s.attachLatestApprovals(c.Context(), purchases); err != nil {
		s.Log.Warnf("Unable to attach latest approvals for purchases: %+v", err)
	}
	return purchases, total, nil
}

func (s *purchaseService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
	scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
	if err != nil {
		return nil, err
	}
	return s.PurchaseRepo.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
}

func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) {
	scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
	if err != nil {
		return nil, err
	}

	purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
		db = s.withRelations(db)
		if scope.Restrict {
			if len(scope.IDs) == 0 {
				return db.Where("1 = 0")
			}
			db = db.Where(
				`EXISTS (
					SELECT 1
					FROM purchase_items pi
					JOIN warehouses w ON w.id = pi.warehouse_id
					WHERE pi.purchase_id = purchases.id AND w.location_id IN ?
				)`,
				scope.IDs,
			)
		}
		return db
	})
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, utils.NotFound("Purchase not found")
		}
		s.Log.Errorf("Failed to get purchase %d: %+v", id, err)
		return nil, utils.Internal("Failed to get purchase")
	}
	if err := s.attachLatestApproval(c.Context(), purchase); err != nil {
		s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
	}
	if len(purchase.Items) > 0 {
		lockedIDs, err := s.resolveChickinLockedItemIDs(c.Context(), s.PurchaseRepo.DB(), purchase.Items)
		if err != nil {
			return nil, err
		}
		for i := range purchase.Items {
			if _, ok := lockedIDs[purchase.Items[i].Id]; ok {
				purchase.Items[i].HasChickin = true
			}
		}
	}
	s.applyTravelDocumentURLs(c.Context(), purchase)

	return purchase, nil
}

func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) {
	if err := s.Validate.Struct(req); err != nil {
		return nil, err
	}

	actorID, err := m.ActorIDFromContext(c)
	if err != nil {
		return nil, err
	}

	if _, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil); err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, utils.NotFound("Supplier not found")
		}
		s.Log.Errorf("Failed to get supplier: %+v", err)
		return nil, utils.Internal("Failed to get supplier")
	}

	type aggregatedItem struct {
		productId   uint
		warehouseId uint
		subQty      float64
		pfkID       *uint
	}

	if len(req.Items) == 0 {
		return nil, utils.BadRequest("Items must not be empty")
	}

	warehouseCache := make(map[uint]*entity.Warehouse)
	productSupplierCache := make(map[uint]bool)
	getWarehouse := func(id uint) (*entity.Warehouse, *uint, error) {
		if warehouse, ok := warehouseCache[id]; ok {
			return warehouse, nil, nil
		}
		warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
			return db.Preload("Area").Preload("Location")
		})

		if err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return nil, nil, utils.NotFound(fmt.Sprintf("Warehouse %d not found", id))
			}
			s.Log.Errorf("Failed to get warehouse %d: %+v", id, err)
			return nil, nil, utils.Internal("Failed to get warehouse")
		}
		var pfkID *uint
		isKandang := strings.EqualFold(strings.TrimSpace(warehouse.Type), "KANDANG")
		if isKandang {
			if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
				return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name))
			}
			if s.ProjectFlockKandangRepo != nil {
				if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil {
					if pfk.ClosedAt != nil {
						return nil, nil, utils.BadRequest("Project sudah closing")
					}
					idCopy := uint(pfk.Id)
					pfkID = &idCopy
				} else if errors.Is(err, gorm.ErrRecordNotFound) {
					return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name))
				} else if err != nil {
					s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err)
					return nil, nil, utils.Internal("Failed to validate project flock")
				}
			}
		}

		warehouseCache[id] = warehouse
		return warehouse, pfkID, nil
	}

	aggregated := make([]*aggregatedItem, 0, len(req.Items))
	indexMap := make(map[string]int)

	for _, item := range req.Items {
		_, pfkID, err := getWarehouse(item.WarehouseID)
		if err != nil {
			return nil, err
		}

		if _, checked := productSupplierCache[item.ProductID]; !checked {
			linked, err := s.ProductRepo.IsLinkedToSupplier(c.Context(), item.ProductID, req.SupplierID)
			if err != nil {
				s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err)
				return nil, utils.Internal("Failed to validate product for supplier")
			}
			if !linked {
				return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID))
			}
			productSupplierCache[item.ProductID] = true
		}

		productId := uint(item.ProductID)
		warehouseId := uint(item.WarehouseID)

		key := fmt.Sprintf("%d:%d", productId, warehouseId)
		if idx, ok := indexMap[key]; ok {
			aggregated[idx].subQty += item.Quantity
			continue
		}

		entry := &aggregatedItem{
			productId:   productId,
			warehouseId: warehouseId,
			subQty:      item.Quantity,
			pfkID:       pfkID,
		}
		aggregated = append(aggregated, entry)
		indexMap[key] = len(aggregated) - 1
	}

	var dueDate *time.Time
	now := time.Now().UTC()
	d := now.AddDate(0, 0, req.CreditTerm)
	dueDate = &d

	purchase := &entity.Purchase{
		SupplierId: uint(req.SupplierID),
		CreditTerm: req.CreditTerm,
		DueDate:    dueDate,
		Notes:      req.Notes,
		CreatedBy:  uint(actorID),
	}

	items := make([]*entity.PurchaseItem, 0, len(aggregated))
	emptyVehicle := ""
	for _, item := range aggregated {
		items = append(items, &entity.PurchaseItem{
			ProductId:             item.productId,
			WarehouseId:           item.warehouseId,
			ProjectFlockKandangId: item.pfkID,
			SubQty:                item.subQty,
			TotalQty:              0,
			TotalUsed:             0,
			Price:                 0,
			TotalPrice:            0,
			VehicleNumber:         &emptyVehicle,
		})
	}

	transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
		purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)

		code, err := purchaseRepoTx.NextPrNumber(c.Context(), tx)
		if err != nil {
			return err
		}
		purchase.PrNumber = code

		if err := purchaseRepoTx.CreateWithItems(c.Context(), purchase, items); err != nil {
			return err
		}

		if err := purchaseRepoTx.BackfillProjectFlockKandang(c.Context(), purchase.Id); err != nil {
			return err
		}

		actorID := uint(purchase.CreatedBy)
		if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil {
			return err
		}

		return nil
	})
	if transactionErr != nil {
		s.Log.Errorf("Failed to create purchase: %+v", transactionErr)
		return nil, utils.Internal("Failed to create purchase")
	}

	created, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
	if err != nil {
		s.Log.Errorf("Failed to load created purchase: %+v", err)
		return nil, utils.Internal("Failed to load purchase")
	}

	if err := s.attachLatestApproval(c.Context(), created); err != nil {
		s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err)
	}
	s.invalidateDepreciationSnapshots(
		c.Context(),
		nil,
		collectPFKIDsFromPurchase(created),
		resolvePurchaseDepreciationInvalidateDate(created, created.Items, now),
	)

	return created, nil
}

func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) {
	if err := s.Validate.Struct(req); err != nil {
		return nil, err
	}

	ctx := c.Context()

	action, err := parseApprovalActionInput(req.Action)
	if err != nil {
		return nil, err
	}

	actorID, err := m.ActorIDFromContext(c)
	if err != nil {
		return nil, err
	}
	purchase, err := s.loadPurchase(ctx, id)
	if err != nil {
		return nil, err
	}

	if action == entity.ApprovalActionApproved {
		if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil {
			return nil, err
		}
	}

	var latestStep uint16
	if purchase.LatestApproval != nil {
		latestStep = purchase.LatestApproval.StepNumber
	}

	if action == entity.ApprovalActionRejected {
		return s.rejectAndReload(c, utils.PurchaseStepStaffPurchase, purchase.Id, actorID, req.Notes)
	}

	isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase)
	if isInitialApproval && latestStep != uint16(utils.PurchaseStepPengajuan) {
		return nil, utils.BadRequest("Purchase is not ready for staff approval")
	}

	hasReceivingData := false
	for _, item := range purchase.Items {
		if item.TotalQty > 0 || item.TotalUsed > 0 {
			hasReceivingData = true
			break
		}
	}

	syncReceiving := !isInitialApproval && hasReceivingData

	if action == entity.ApprovalActionApproved && len(req.Items) == 0 {
		return nil, utils.BadRequest("Items must not be empty for staff approval")
	}

	if action == entity.ApprovalActionApproved {
		itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items))
		for i := range purchase.Items {
			if purchase.Items[i].Id == 0 {
				continue
			}
			itemByID[purchase.Items[i].Id] = purchase.Items[i]
		}
		lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items)
		if err != nil {
			return nil, err
		}
		if len(lockedIDs) > 0 {
			for _, payload := range req.Items {
				if payload.PurchaseItemID == 0 || payload.Qty == nil {
					continue
				}
				if _, locked := lockedIDs[payload.PurchaseItemID]; !locked {
					continue
				}
				item, ok := itemByID[payload.PurchaseItemID]
				if !ok {
					continue
				}
				if *payload.Qty != item.SubQty {
					return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah")
				}
			}
		}
	}

	payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving)
	if err != nil {
		return nil, err
	}

	transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
		purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
		if len(payload.PricingUpdates) > 0 {
			if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates); err != nil {
				return err
			}
		}

		if len(payload.NewItems) > 0 {
			if err := purchaseRepoTx.CreateItems(c.Context(), purchase.Id, payload.NewItems); err != nil {
				return err
			}
		}

		if isInitialApproval {
			if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil {
				return err
			}
			return nil
		}

		if len(payload.PricingUpdates) > 0 || len(payload.NewItems) > 0 {
			if err := s.createPurchaseApproval(
				c.Context(),
				tx,
				purchase.Id,
				utils.PurchaseStepStaffPurchase,
				entity.ApprovalActionUpdated,
				actorID,
				req.Notes,
				true, // allowDuplicate = true supaya boleh UPDATED berkali2
			); err != nil {
				return err
			}
		}

		return nil
	})
	if transactionErr != nil {
		if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
			return nil, utils.NotFound("Purchase item not found")
		}
		if isInitialApproval {
			s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr)
			return nil, utils.Internal("Failed to approve purchase")
		}
		return nil, utils.Internal("Failed to update purchase pricing")
	}

	updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
	if err != nil {
		return nil, utils.Internal("Failed to load purchase")
	}
	if err := s.attachLatestApproval(c.Context(), updated); err != nil {
		s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
	}
	s.invalidateDepreciationSnapshots(
		c.Context(),
		nil,
		collectPFKIDsFromPurchase(updated),
		resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()),
	)

	return updated, nil
}

func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) {
	if err := s.Validate.Struct(req); err != nil {
		return nil, err
	}

	action, err := parseApprovalActionInput(req.Action)
	if err != nil {
		return nil, err
	}

	actorID, err := m.ActorIDFromContext(c)
	if err != nil {
		return nil, err
	}

	purchase, err := s.loadPurchase(c.Context(), id)
	if err != nil {
		return nil, err
	}

	if action == entity.ApprovalActionApproved {
		if err := s.ensureProjectFlockNotClosedForPurchase(c.Context(), purchase); err != nil {
			return nil, err
		}
	}
	if purchase.LatestApproval == nil ||
		purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) {
		return nil, utils.BadRequest("Purchase must reach staff purchase step before manager approval")
	}

	if action == entity.ApprovalActionRejected {
		return s.rejectAndReload(c, utils.PurchaseStepManager, purchase.Id, actorID, req.Notes)
	}

	now := time.Now().UTC()
	hasExistingPO := purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != ""
	var generatedNumber string

	transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
		updateData := map[string]any{}
		if !hasExistingPO {
			repoTx := rPurchase.NewPurchaseRepository(tx)
			code, err := repoTx.NextPoNumber(c.Context(), tx)
			if err != nil {
				return err
			}
			updateData["po_number"] = code
			updateData["po_date"] = now
			generatedNumber = code
		}

		if len(updateData) > 0 {
			repoTx := rPurchase.NewPurchaseRepository(tx)
			if err := repoTx.PatchOne(c.Context(), uint(purchase.Id), updateData, nil); err != nil {
				return err
			}
		}

		forceManagerApproval := false
		approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
		if approvalSvcTx != nil {
			filterByStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB {
				return func(db *gorm.DB) *gorm.DB {
					return db.Where("step_number = ?", uint16(step))
				}
			}
			latestStaff, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase))
			if err != nil {
				return err
			}
			latestManager, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager))
			if err != nil {
				return err
			}
			if latestStaff != nil && latestManager != nil && latestStaff.ActionAt.After(latestManager.ActionAt) {
				forceManagerApproval = true
			}
		}

		if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil {
			return err
		}

		return nil
	})
	if transactionErr != nil {
		s.Log.Errorf("Failed to approve manager purchase %d: %+v", purchase.Id, transactionErr)
		return nil, utils.Internal("Failed to generate purchase order")
	}

	if generatedNumber != "" {
		purchase.PoNumber = &generatedNumber
		purchase.PoDate = &now
	}

	updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
	if err != nil {
		s.Log.Errorf("Failed to load purchase after manager approval: %+v", err)
		return nil, utils.Internal("Failed to load purchase")
	}

	if err := s.attachLatestApproval(c.Context(), updated); err != nil {
		s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
	}
	s.invalidateDepreciationSnapshots(
		c.Context(),
		nil,
		collectPFKIDsFromPurchase(updated),
		resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, now),
	)

	return updated, nil
}

func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) {
	if err := s.Validate.Struct(req); err != nil {
		return nil, err
	}
	ctx := c.Context()
	action, err := parseApprovalActionInput(req.Action)
	if err != nil {
		return nil, err
	}

	actorID, err := m.ActorIDFromContext(c)
	if err != nil {
		return nil, err
	}

	purchase, err := s.loadPurchase(ctx, id)
	if err != nil {
		return nil, err
	}

	if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" {
		return nil, utils.BadRequest("Purchase order has not been generated")
	}

	if purchase.LatestApproval == nil ||
		purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) {
		return nil, utils.BadRequest("Purchase must be approved by manager before receiving products")
	}
	if action == entity.ApprovalActionApproved {
		if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil {
			return nil, err
		}
	}
	if action == entity.ApprovalActionApproved && len(req.Items) == 0 {
		return nil, utils.BadRequest("Receiving data must not be empty")
	}

	if action == entity.ApprovalActionRejected {
		if err := s.createPurchaseApproval(ctx, nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil {
			return nil, err
		}
		updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations)
		if err != nil {
			return nil, utils.Internal("Failed to load purchase")
		}
		if err := s.attachLatestApproval(ctx, updated); err != nil {
			s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
		}
		return updated, nil
	}

	if action == entity.ApprovalActionApproved && len(req.TravelDocuments) > 0 {
		if len(req.TravelDocuments) > len(req.Items) {
			return nil, utils.BadRequest("Travel documents exceed total receiving items")
		}
		for idx, file := range req.TravelDocuments {
			if file == nil {
				continue
			}
			if idx >= len(req.Items) {
				break
			}
			itemID := req.Items[idx].PurchaseItemID
			if itemID == 0 {
				return nil, utils.BadRequest("Purchase item id is required for travel document upload")
			}
			uploadedURL, err := s.uploadTravelDocument(ctx, actorID, itemID, file)
			if err != nil {
				s.Log.Errorf("Failed to upload travel document for item %d: %+v", itemID, err)
				return nil, utils.Internal("Failed to upload travel document")
			}
			req.Items[idx].TravelDocumentPath = &uploadedURL
		}
	}
	lockedIDs := map[uint]struct{}{}
	if action == entity.ApprovalActionApproved {
		itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items))
		for i := range purchase.Items {
			if purchase.Items[i].Id == 0 {
				continue
			}
			itemByID[purchase.Items[i].Id] = purchase.Items[i]
		}
		locked, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items)
		if err != nil {
			return nil, err
		}
		if len(locked) > 0 {
			for id := range locked {
				lockedIDs[id] = struct{}{}
			}
			for _, payload := range req.Items {
				if _, used := lockedIDs[payload.PurchaseItemID]; !used {
					continue
				}
				item, ok := itemByID[payload.PurchaseItemID]
				if !ok {
					continue
				}
				receivedQty := item.SubQty
				if payload.ReceivedQty != nil {
					receivedQty = *payload.ReceivedQty
				}
				if receivedQty != item.TotalQty {
					return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah")
				}
			}
		}
	}

	itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items))
	for i := range purchase.Items {
		itemMap[purchase.Items[i].Id] = &purchase.Items[i]
	}

	type preparedReceiving struct {
		item              *entity.PurchaseItem
		payload           validation.ReceivePurchaseItemRequest
		receivedDate      time.Time
		warehouseID       uint
		supplierID        uint
		transportPerItem  *float64
		vehicleNumber     *string
		clearVehicle      bool
		overrideWarehouse bool
		receivedQty       float64
	}

	visitedItems := make(map[uint]struct{}, len(req.Items))
	prepared := make([]preparedReceiving, 0, len(req.Items))
	var earliestReceived *time.Time
	for _, payload := range req.Items {
		item, exists := itemMap[payload.PurchaseItemID]
		if !exists {
			return nil, utils.BadRequest(fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID))
		}

		receivedDate, err := utils.ParseDateString(payload.ReceivedDate)
		if err != nil {
			return nil, utils.BadRequest(fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID))
		}
		receivedDate = receivedDate.UTC()
		if earliestReceived == nil || receivedDate.Before(*earliestReceived) {
			copy := receivedDate
			earliestReceived = &copy
		}

		warehouseID := uint(item.WarehouseId)
		overrideWarehouse := false
		if payload.WarehouseID != nil && *payload.WarehouseID != 0 {
			warehouseID = *payload.WarehouseID
			overrideWarehouse = true
		}
		if warehouseID == 0 {
			return nil, utils.BadRequest(fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID))
		}
		if payload.WarehouseID != nil && uint(item.WarehouseId) != warehouseID {
			return nil, utils.BadRequest("Receiving does not allow changing warehouse")
		}

		var receivedQty float64
		if payload.ReceivedQty != nil {
			receivedQty = *payload.ReceivedQty
		} else {
			receivedQty = item.SubQty
		}
		if receivedQty < 0 {
			return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID))
		}
		if receivedQty > item.SubQty {
			return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty))
		}
		if receivedQty < item.TotalUsed {
			deps, allowPending, err := s.resolvePurchaseDependenciesAndPendingPolicy(
				ctx,
				s.PurchaseRepo.DB(),
				[]uint{item.Id},
			)
			if err != nil {
				return nil, err
			}
			if len(deps) > 0 && !allowPending {
				return nil, utils.BadRequest(
					fmt.Sprintf(
						"Received quantity for item %d cannot be lower than used amount (%.3f). Dependensi aktif: %s. Alasan block: pending disabled by config.",
						payload.PurchaseItemID,
						item.TotalUsed,
						formatPurchaseDependencySummary(deps),
					),
				)
			}
			if len(deps) == 0 && isReceivingBelowUsedBlocked(item, lockedIDs) {
				return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed))
			}
		}

		if _, dup := visitedItems[payload.PurchaseItemID]; dup {
			return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID))
		}
		visitedItems[payload.PurchaseItemID] = struct{}{}

		var supplierID uint
		if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 {
			supplierID = *payload.ExpeditionVendorID
		}

		var transportPerItem *float64
		if payload.TransportPerItem != nil {
			if *payload.TransportPerItem < 0 {
				return nil, utils.BadRequest(fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID))
			}
			val := *payload.TransportPerItem
			transportPerItem = &val
		}

		var vehicleNumber *string
		clearVehicle := false
		if payload.VehicleNumber != nil {
			val := strings.TrimSpace(*payload.VehicleNumber)
			if val != "" {
				vehicleNumber = &val
			} else {
				clearVehicle = true
			}
		} else {
			clearVehicle = true
		}

		prepared = append(prepared, preparedReceiving{
			item:              item,
			payload:           payload,
			receivedDate:      receivedDate,
			warehouseID:       warehouseID,
			supplierID:        supplierID,
			transportPerItem:  transportPerItem,
			vehicleNumber:     vehicleNumber,
			clearVehicle:      clearVehicle,
			overrideWarehouse: overrideWarehouse,
			receivedQty:       receivedQty,
		})
	}

	// Require receiving payload to cover all purchase items so that
	// receiving cannot be submitted partially item-by-item.
	if len(visitedItems) != len(itemMap) {
		return nil, utils.BadRequest("Receiving data must be provided for all purchase items")
	}

	receivingAction := action
	completedAction := entity.ApprovalActionApproved
	approvalSvc := commonSvc.NewApprovalService(
		commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()),
	)

	if approvalSvc != nil {
		filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB {
			return func(db *gorm.DB) *gorm.DB {
				return db.Where("step_number = ?", uint16(step))
			}
		}

		latestReceiving, err := approvalSvc.LatestByTarget(
			c.Context(),
			utils.ApprovalWorkflowPurchase,
			uint(purchase.Id),
			filterStep(utils.PurchaseStepReceiving),
		)
		if err != nil {
			s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err)
			return nil, utils.Internal("Failed to record purchase receiving")
		}

		if latestReceiving != nil {
			receivingAction = entity.ApprovalActionUpdated
		}
	}
	noteSuffix := "receive"
	if receivingAction == entity.ApprovalActionUpdated {
		noteSuffix = "edit-receive"
	}
	receiveNote := fmt.Sprintf("%s#%s", strings.TrimSpace(*purchase.PoNumber), noteSuffix)

	transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
		repoTx := rPurchase.NewPurchaseRepository(tx)
		pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx)
		stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)

		affected := make(map[uint]struct{})
		updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared))
		priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared))
		totalQtyDeltas := make(map[uint]float64)
		reflowAsOfByPW := make(map[uint]time.Time)
		logEntries := make([]struct {
			itemID uint
			pwID   uint
			delta  float64
		}, 0, len(prepared))

		for _, prep := range prepared {
			item := prep.item

			var newPWID *uint

			// Always ensure PW after receiving so linkage stays stable.
			pwID, err := pwRepoTx.EnsureProductWarehouse(
				c.Context(),
				uint(item.ProductId),
				prep.warehouseID,
				item.ProjectFlockKandangId,
				purchase.CreatedBy,
			)
			if err != nil {
				return err
			}
			// Safety: ensure the PW we got matches the purchase item product.
			if pwDetail, err := pwRepoTx.GetDetailByID(c.Context(), pwID); err != nil {
				return err
			} else if pwDetail.ProductId != uint(item.ProductId) {
				return fiber.NewError(
					fiber.StatusBadRequest,
					fmt.Sprintf("Product warehouse %d belongs to product %d, not purchase item product %d", pwID, pwDetail.ProductId, item.ProductId),
				)
			}
			newPWID = &pwID

			deltaQty := prep.receivedQty - item.TotalQty
			if newPWID != nil && deltaQty != 0 {
				logEntries = append(logEntries, struct {
					itemID uint
					pwID   uint
					delta  float64
				}{itemID: item.Id, pwID: *newPWID, delta: deltaQty})
			}
			if newPWID != nil {
				assignEarliestAsOf(reflowAsOfByPW, *newPWID, prep.receivedDate.UTC())
			}
			if deltaQty != 0 {
				totalQtyDeltas[item.Id] += deltaQty
			}
			if deltaQty < 0 && newPWID != nil {
				affected[*newPWID] = struct{}{}
			}

			dateCopy := prep.receivedDate
			qtyCopy := prep.receivedQty
			update := rPurchase.PurchaseReceivingUpdate{
				ItemID:                item.Id,
				ReceivedDate:          &dateCopy,
				TravelNumber:          prep.payload.TravelNumber,
				TravelDocumentPath:    prep.payload.TravelDocumentPath,
				VehicleNumber:         prep.vehicleNumber,
				ClearVehicleNumber:    prep.clearVehicle,
				ReceivedQty:           &qtyCopy,
				ProductWarehouseID:    newPWID,
				ClearProductWarehouse: false,
			}

			if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID {
				warehouseCopy := prep.warehouseID
				update.WarehouseID = &warehouseCopy
			}

			updates = append(updates, update)

			if prep.receivedQty >= 0 {
				priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{
					ItemID:     item.Id,
					Price:      item.Price,
					TotalPrice: item.Price * prep.receivedQty,
				})
			}
		}

		if err := repoTx.UpdateReceivingDetails(c.Context(), purchase.Id, updates); err != nil {
			return err
		}

		if len(priceUpdates) > 0 {
			if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil {
				return err
			}
		}

		if len(totalQtyDeltas) > 0 {
			for itemID, delta := range totalQtyDeltas {
				if delta == 0 {
					continue
				}
				if err := tx.Model(&entity.PurchaseItem{}).
					Where("purchase_id = ? AND id = ?", purchase.Id, itemID).
					Update("total_qty", gorm.Expr("COALESCE(total_qty,0) + ?", delta)).Error; err != nil {
					return err
				}
			}
		}

		// Update due_date based on earliest received date when receiving approved.
		if earliestReceived != nil {
			due := earliestReceived.AddDate(0, 0, purchase.CreditTerm)
			if err := tx.Model(&entity.Purchase{}).
				Where("id = ?", purchase.Id).
				Update("due_date", due).Error; err != nil {
				return err
			}
		}

		if len(reflowAsOfByPW) > 0 {
			if s.FifoStockV2Svc == nil {
				return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
			}
			for pwID, asOf := range reflowAsOfByPW {
				asOfCopy := asOf
				if err := reflowPurchaseScope(c.Context(), s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil {
					return err
				}
			}
		}

		if len(logEntries) > 0 {
			logs := make([]*entity.StockLog, 0, len(logEntries))
			for _, entry := range logEntries {
				if entry.pwID == 0 || entry.delta == 0 {
					continue
				}
				log := &entity.StockLog{
					ProductWarehouseId: entry.pwID,
					CreatedBy:          actorID,
					LoggableType:       string(utils.StockLogTypePurchase),
					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)
			}
			if len(logs) > 0 {
				if err := stockLogRepoTx.CreateMany(c.Context(), logs, nil); err != nil {
					return err
				}
			}
		}

		if len(affected) > 0 {
			if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil {
				return err
			}
		}

		return nil
	})
	if transactionErr != nil {
		if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
			return nil, utils.NotFound("Purchase item not found for receiving")
		}
		s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr)
		return nil, utils.Internal("Failed to record purchase receiving")
	}

	updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
	if err != nil {
		return nil, utils.Internal("Failed to load purchase ")
	}
	if err := s.attachLatestApproval(c.Context(), updated); err != nil {
		s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
	}
	invalidateFromDate := resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC())
	if earliestReceived != nil {
		invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, *earliestReceived)
	}
	s.invalidateDepreciationSnapshots(
		c.Context(),
		nil,
		collectPFKIDsFromPurchase(updated),
		invalidateFromDate,
	)

	receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared))
	for _, prep := range prepared {
		date := prep.receivedDate
		payload := ExpenseReceivingPayload{
			PurchaseItemID:   prep.item.Id,
			ProductID:        prep.item.ProductId,
			WarehouseID:      uint(prep.warehouseID),
			SupplierID:       prep.supplierID,
			TransportPerItem: prep.transportPerItem,
			ReceivedQty:      prep.receivedQty,
			ReceivedDate:     &date,
			VehicleNumber:    prep.vehicleNumber,
		}
		receivingPayloads = append(receivingPayloads, payload)
	}
	if err := s.notifyExpenseItemsReceived(c, purchase.Id, receivingPayloads); err != nil {
		s.Log.Errorf("Failed to sync expense for purchase %d: %+v", purchase.Id, err)
		if fe, ok := err.(*fiber.Error); ok {
			return nil, fe
		}
		return nil, utils.Internal("Failed to sync expense")
	}

	// Create approvals only after expense sync succeeds
	if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil {
		return nil, err
	}
	if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil {
		return nil, err
	}

	return updated, nil
}

func (s *purchaseService) uploadTravelDocument(
	ctx context.Context,
	actorID uint,
	itemID uint,
	file *multipart.FileHeader,
) (string, error) {
	if file == nil {
		return "", errors.New("travel document file is required")
	}
	if s.DocumentSvc == nil {
		return "", errors.New("document service not available")
	}

	documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(itemID))
	if err != nil {
		return "", err
	}
	if len(documents) > 0 {
		var ids []uint
		for _, doc := range documents {
			if doc.Type == string(utils.DocumentTypePurchaseTravel) {
				ids = append(ids, doc.Id)
			}
		}
		if err := s.DocumentSvc.DeleteDocuments(ctx, ids, true); err != nil {
			return "", err
		}
	}

	documentFiles := []commonSvc.DocumentFile{{
		File: file,
		Type: string(utils.DocumentTypePurchaseTravel),
	}}
	results, err := s.DocumentSvc.UploadDocuments(ctx, commonSvc.DocumentUploadRequest{
		DocumentableType: string(utils.DocumentableTypePurchaseItem),
		DocumentableID:   uint64(itemID),
		CreatedBy:        &actorID,
		Files:            documentFiles,
	})
	if err != nil {
		return "", err
	}
	if len(results) == 0 {
		return "", errors.New("upload result is empty")
	}
	return results[0].Document.Path, nil
}

func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) {
	if err := s.Validate.Struct(req); err != nil {
		return nil, err
	}

	ctx := c.Context()
	purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, utils.NotFound("Purchase not found")
		}
		return nil, utils.Internal("Failed to get purchase")
	}

	if err := s.attachLatestApproval(ctx, purchase); err != nil {
		s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
	}
	if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil {
		return nil, err
	}
	if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) {
		return nil, utils.BadRequest("Purchase cannot delete items before staff purchase approval")
	}

	if len(purchase.Items) == 0 {
		return nil, utils.BadRequest("Purchase has no items to delete")
	}

	requested := make(map[uint]struct{}, len(req.ItemIDs))
	for _, id := range req.ItemIDs {
		requested[id] = struct{}{}
	}

	toDelete := make([]uint, 0, len(req.ItemIDs))
	var remainingTotal float64

	for _, item := range purchase.Items {
		if _, ok := requested[item.Id]; ok {
			toDelete = append(toDelete, item.Id)
		} else {
			remainingTotal += item.TotalPrice
		}
	}

	if len(toDelete) == 0 {
		return nil, utils.BadRequest("Requested items were not found in this purchase")
	}

	toDeleteSet := make(map[uint]struct{}, len(toDelete))
	for _, id := range toDelete {
		toDeleteSet[id] = struct{}{}
	}
	itemsToDelete := make([]entity.PurchaseItem, 0, len(toDelete))
	for _, item := range purchase.Items {
		if _, ok := toDeleteSet[item.Id]; ok {
			itemsToDelete = append(itemsToDelete, item)
		}
	}

	if len(purchase.Items)-len(toDelete) <= 0 {
		return nil, utils.BadRequest("Purchase must keep at least one item")
	}

	transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		repoTx := rPurchase.NewPurchaseRepository(tx)
		var lockedPurchase entity.Purchase
		if err := tx.WithContext(ctx).
			Clauses(clause.Locking{Strength: "UPDATE"}).
			Where("id = ?", purchase.Id).
			Take(&lockedPurchase).Error; err != nil {
			return err
		}

		allowPending, deps, err := s.ensurePurchaseDeletePolicy(ctx, tx, toDelete)
		if err != nil {
			return err
		}
		if len(deps) > 0 && !allowPending {
			return utils.BadRequest(
				fmt.Sprintf(
					"Purchase item tidak dapat dihapus karena sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.",
					formatPurchaseDependencySummary(deps),
				),
			)
		}

		if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, fmt.Sprintf("Purchase-Item-Delete#%d", purchase.Id), purchase.CreatedBy); err != nil {
			return err
		}

		if err := repoTx.DeleteItems(ctx, purchase.Id, toDelete); err != nil {
			return err
		}

		return nil
	})
	if transactionErr != nil {
		if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
			return nil, utils.NotFound("Purchase item not found")
		}
		return nil, utils.Internal("Failed to delete purchase items")
	}

	if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil {
		return nil, utils.Internal("Failed to delete purchase documents")
	}

	if len(itemsToDelete) > 0 {
		if err := s.notifyExpenseItemsDeleted(ctx, purchase.Id, itemsToDelete); err != nil {
			s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", purchase.Id, err)
			if fe, ok := err.(*fiber.Error); ok {
				return nil, fe
			}
			return nil, utils.Internal("Failed to sync expense")
		}
	}

	updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations)
	if err != nil {
		return nil, utils.Internal("Failed to load purchase")
	}
	if err := s.attachLatestApproval(ctx, updated); err != nil {
		s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
	}
	s.invalidateDepreciationSnapshots(
		ctx,
		nil,
		collectPFKIDsFromPurchaseItems(itemsToDelete),
		resolvePurchaseDepreciationInvalidateDate(purchase, itemsToDelete, time.Now().UTC()),
	)

	return updated, nil
}

func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
	if id == 0 {
		return utils.BadRequest("Invalid purchase id")
	}

	ctx := c.Context()
	actorID, err := m.ActorIDFromContext(c)
	if err != nil {
		return err
	}
	purchase, err := s.loadPurchase(ctx, id)
	if err != nil {
		return err
	}
	if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil {
		return err
	}

	itemsToDelete := make([]entity.PurchaseItem, len(purchase.Items))
	for i, item := range purchase.Items {
		itemsToDelete[i] = item
	}

	note := fmt.Sprintf("Purchase-Delete#%d", purchase.Id)
	if purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" {
		note = fmt.Sprintf("%s#delete", strings.TrimSpace(*purchase.PoNumber))
	}

	transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		var lockedPurchase entity.Purchase
		if err := tx.WithContext(ctx).
			Clauses(clause.Locking{Strength: "UPDATE"}).
			Where("id = ?", purchase.Id).
			Take(&lockedPurchase).Error; err != nil {
			return err
		}

		allowPending, deps, err := s.ensurePurchaseDeletePolicy(ctx, tx, collectPurchaseItemIDs(itemsToDelete))
		if err != nil {
			return err
		}
		if len(deps) > 0 && !allowPending {
			return utils.BadRequest(
				fmt.Sprintf(
					"Purchase tidak dapat dihapus karena sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.",
					formatPurchaseDependencySummary(deps),
				),
			)
		}

		if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil {
			return err
		}

		approvalRepoTx := commonRepo.NewApprovalRepository(tx)
		if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowPurchase.String(), uint(id)); err != nil {
			return err
		}

		purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
		if err := purchaseRepoTx.DeleteOne(ctx, uint(id)); err != nil {
			return err
		}
		return nil
	})
	if transactionErr != nil {
		var fe *fiber.Error
		if errors.As(transactionErr, &fe) {
			return fe
		}
		if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
			return utils.NotFound("Purchase not found")
		}
		return utils.Internal("Failed to delete purchase")
	}

	if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil {
		return utils.Internal("Failed to delete purchase documents")
	}

	if len(itemsToDelete) > 0 {
		if err := s.notifyExpenseItemsDeleted(ctx, uint(id), itemsToDelete); err != nil {
			s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", id, err)
			if fe, ok := err.(*fiber.Error); ok {
				return fe
			}
			return utils.Internal("Failed to sync expense")
		}
	}
	s.invalidateDepreciationSnapshots(
		ctx,
		nil,
		collectPFKIDsFromPurchaseItems(itemsToDelete),
		resolvePurchaseDepreciationInvalidateDate(purchase, itemsToDelete, time.Now().UTC()),
	)

	return nil
}

func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB, items []entity.PurchaseItem, note string, actorID uint) error {
	if len(items) == 0 {
		return nil
	}

	stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
	affected := make(map[uint]struct{})
	reflowAsOfByPW := make(map[uint]time.Time)
	logEntries := make([]struct {
		pwID uint
		qty  float64
	}, 0, len(items))

	for _, item := range items {
		if item.ProductWarehouseId == nil || *item.ProductWarehouseId == 0 {
			continue
		}
		if item.TotalQty == 0 {
			continue
		}
		pwID := *item.ProductWarehouseId
		qty := item.TotalQty

		if err := tx.WithContext(ctx).
			Model(&entity.PurchaseItem{}).
			Where("id = ?", item.Id).
			Update("total_qty", 0).Error; err != nil {
			return err
		}

		affected[pwID] = struct{}{}
		if item.ReceivedDate != nil {
			assignEarliestAsOf(reflowAsOfByPW, pwID, item.ReceivedDate.UTC())
		} else {
			assignEarliestAsOf(reflowAsOfByPW, pwID, time.Now().UTC())
		}
		logEntries = append(logEntries, struct {
			pwID uint
			qty  float64
		}{pwID: pwID, qty: qty})
	}

	if len(reflowAsOfByPW) > 0 {
		if s.FifoStockV2Svc == nil {
			return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
		}
		for pwID, asOf := range reflowAsOfByPW {
			asOfCopy := asOf
			if err := reflowPurchaseScope(ctx, s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil {
				return err
			}
		}
	}

	if len(affected) > 0 {
		if err := rProductWarehouse.NewProductWarehouseRepository(tx).CleanupEmpty(ctx, affected); err != nil {
			return err
		}
	}

	if strings.TrimSpace(note) != "" && actorID != 0 && len(logEntries) > 0 {
		logs := make([]*entity.StockLog, 0, len(logEntries))
		for _, entry := range logEntries {
			if entry.pwID == 0 || entry.qty <= 0 {
				continue
			}
			logs = append(logs, &entity.StockLog{
				ProductWarehouseId: entry.pwID,
				CreatedBy:          actorID,
				Decrease:           entry.qty,
				LoggableType:       string(utils.StockLogTypePurchase),
				LoggableId:         items[0].PurchaseId,
				Notes:              note,
			})
		}
		if len(logs) > 0 {
			if err := stockLogRepoTx.CreateMany(ctx, logs, nil); err != nil {
				return err
			}
		}
	}

	return nil
}

func (s *purchaseService) createPurchaseApproval(
	ctx context.Context,
	db *gorm.DB,
	purchaseID uint,
	step approvalutils.ApprovalStep,
	action entity.ApprovalAction,
	actorID uint,
	notes *string,
	allowDuplicate bool,
) error {
	if purchaseID == 0 {
		return utils.BadRequest("Purchase is invalid for approval")
	}
	if actorID == 0 {
		actorID = 1
	}

	svc := s.approvalServiceForDB(db)
	if svc == nil {
		return utils.Internal("Approval service not available")
	}

	modifier := func(db *gorm.DB) *gorm.DB {
		return db.Where("step_number = ?", uint16(step))
	}

	latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier)
	if err != nil {
		return err
	}

	if !allowDuplicate && latest != nil &&
		latest.Action != nil &&
		*latest.Action == action {
		return nil
	}

	actionCopy := action
	_, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes)
	return err
}

func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService {
	if db != nil {
		return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db))
	}
	if s.ApprovalSvc != nil {
		return s.ApprovalSvc
	}
	if s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil {
		return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()))
	}
	return nil
}

func (s *purchaseService) notifyExpenseItemsReceived(c *fiber.Ctx, purchaseID uint, payloads []ExpenseReceivingPayload) error {
	if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 {
		return nil
	}
	return s.ExpenseBridge.OnItemsReceived(c, purchaseID, payloads)
}

func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error {
	if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 {
		return nil
	}
	return s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, items)

}

func (s *purchaseService) deletePurchaseItemDocuments(ctx context.Context, items []entity.PurchaseItem) error {
	if s.DocumentSvc == nil || len(items) == 0 {
		return nil
	}
	for _, item := range items {
		if item.Id == 0 {
			continue
		}
		if err := s.DocumentSvc.DeleteByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id), true); err != nil {
			return err
		}
	}
	return nil
}

func (s *purchaseService) buildStaffAdjustmentPayload(
	ctx context.Context,
	purchase *entity.Purchase,
	req *validation.ApproveStaffPurchaseRequest,
	syncReceiving bool,
) (*staffAdjustmentPayload, error) {
	if len(req.Items) == 0 {
		return nil, utils.BadRequest("Items must not be empty")
	}

	requestItems := make(map[uint]validation.StaffPurchaseApprovalItem, len(req.Items))
	newPayloads := make([]validation.StaffPurchaseApprovalItem, 0)

	for _, item := range req.Items {
		if item.PurchaseItemID == 0 {
			newPayloads = append(newPayloads, item)
			continue
		}
		if _, exists := requestItems[item.PurchaseItemID]; exists {
			return nil, utils.BadRequest(fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID))
		}
		requestItems[item.PurchaseItemID] = item
	}

	updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items))

	existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads))
	for _, item := range purchase.Items {
		key := fmt.Sprintf("%d:%d", item.ProductId, item.WarehouseId)
		existingCombos[key] = struct{}{}
	}

	allowedWarehouses := make(map[uint]struct{}, len(purchase.Items))
	for _, item := range purchase.Items {
		allowedWarehouses[item.WarehouseId] = struct{}{}
	}
	if len(allowedWarehouses) == 0 && len(newPayloads) > 0 {
		return nil, utils.BadRequest("No available warehouses for this purchase")
	}

	for _, item := range purchase.Items {
		data, ok := requestItems[item.Id]
		if !ok {
			return nil, utils.BadRequest(fmt.Sprintf("Missing pricing data for item %d", item.Id))
		}
		if data.ProductID != 0 && data.ProductID != item.ProductId {
			return nil, utils.BadRequest(fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id))
		}
		if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId {
			return nil, utils.BadRequest(fmt.Sprintf("Warehouse mismatch for item %d", item.Id))
		}

		effectiveQty := item.SubQty
		if data.Qty != nil {
			if *data.Qty <= 0 {
				return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id))
			}
			if item.TotalUsed > 0 && *data.Qty < item.TotalUsed && isReceivingBelowUsedBlocked(&item, nil) {
				return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed))
			}
			if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving {
				return nil, utils.BadRequest(fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id))
			}
			effectiveQty = *data.Qty
		}

		totalPriceInput := data.TotalPrice
		totalPrice, err := calculateTotalPrice(effectiveQty, data.Price, &totalPriceInput, fmt.Sprintf("item %d", item.Id))
		if err != nil {
			return nil, err
		}

		update := rPurchase.PurchasePricingUpdate{
			ItemID:     item.Id,
			Price:      data.Price,
			TotalPrice: totalPrice,
		}
		if data.Qty != nil {
			qtyCopy := effectiveQty
			update.Quantity = &qtyCopy
		}

		updates = append(updates, update)
		delete(requestItems, item.Id)
	}
	if len(requestItems) > 0 {
		return nil, utils.BadRequest("Found pricing data for items that do not belong to this purchase")
	}

	productSupplierCache := make(map[uint]bool)
	newItems := make([]*entity.PurchaseItem, 0, len(newPayloads))
	emptyVehicle := ""

	for _, payload := range newPayloads {
		if payload.ProductID == 0 || payload.WarehouseID == 0 {
			return nil, utils.BadRequest("Product and warehouse must be provided for new items")
		}
		if payload.Qty == nil || *payload.Qty <= 0 {
			return nil, utils.BadRequest(fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID))
		}
		if _, ok := allowedWarehouses[payload.WarehouseID]; !ok {
			return nil, utils.BadRequest(fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID))
		}

		key := fmt.Sprintf("%d:%d", payload.ProductID, payload.WarehouseID)
		if _, exists := existingCombos[key]; exists {
			return nil, utils.BadRequest(fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID))
		}

		if _, checked := productSupplierCache[payload.ProductID]; !checked {
			linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(payload.ProductID), uint(purchase.SupplierId))
			if err != nil {
				s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", payload.ProductID, purchase.SupplierId, err)
				return nil, utils.Internal("Failed to validate product for supplier")
			}
			if !linked {
				return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId))
			}
			productSupplierCache[payload.ProductID] = true
		}

		qty := *payload.Qty
		totalPriceInput := payload.TotalPrice
		totalPrice, err := calculateTotalPrice(qty, payload.Price, &totalPriceInput, fmt.Sprintf("product %d in warehouse %d", payload.ProductID, payload.WarehouseID))
		if err != nil {
			return nil, err
		}

		newItem := &entity.PurchaseItem{
			PurchaseId:    purchase.Id,
			ProductId:     payload.ProductID,
			WarehouseId:   payload.WarehouseID,
			SubQty:        qty,
			TotalQty:      0,
			TotalUsed:     0,
			Price:         payload.Price,
			TotalPrice:    totalPrice,
			VehicleNumber: &emptyVehicle,
		}
		newItems = append(newItems, newItem)
		existingCombos[key] = struct{}{}
	}

	if len(updates) == 0 && len(newItems) == 0 {
		return nil, utils.BadRequest("Purchase has no items to process")
	}

	return &staffAdjustmentPayload{
		PricingUpdates: updates,
		NewItems:       newItems,
	}, nil
}

// ? helper
func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) {
	if quantity <= 0 {
		return 0, utils.BadRequest(fmt.Sprintf("Quantity for %s must be greater than 0", ref))
	}
	if price <= 0 {
		return 0, utils.BadRequest(fmt.Sprintf("Price for %s must be greater than 0", ref))
	}

	expectedTotal := price * quantity

	if provided == nil {
		return expectedTotal, nil
	}
	if *provided <= 0 {
		return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must be greater than 0", ref))
	}
	if math.Abs(*provided-expectedTotal) > priceTolerance {
		return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must equal quantity x price", ref))
	}
	return *provided, nil
}

func purchaseItemHasFlag(item *entity.PurchaseItem, flag utils.FlagType) bool {
	if item == nil || item.Product == nil {
		return false
	}
	target := utils.NormalizeFlag(string(flag))
	for _, f := range item.Product.Flags {
		if utils.NormalizeFlag(f.Name) == target {
			return true
		}
	}
	return false
}

func (s *purchaseService) ensurePurchaseDeletePolicy(
	ctx context.Context,
	tx *gorm.DB,
	itemIDs []uint,
) (bool, []purchaseDownstreamDependency, error) {
	deps, allowPending, err := s.resolvePurchaseDependenciesAndPendingPolicy(ctx, tx, itemIDs)
	if err != nil {
		return false, nil, err
	}
	return allowPending, deps, nil
}

func (s *purchaseService) resolvePurchaseDependenciesAndPendingPolicy(
	ctx context.Context,
	tx *gorm.DB,
	itemIDs []uint,
) ([]purchaseDownstreamDependency, bool, error) {
	deps, err := s.loadPurchaseDownstreamDependencies(ctx, tx, itemIDs)
	if err != nil {
		s.Log.Errorf("Failed to load downstream dependencies for purchase items: %+v", err)
		return nil, false, utils.Internal("Failed to validate downstream purchase dependencies")
	}
	if len(deps) == 0 {
		return nil, true, nil
	}

	allowPending := true
	for _, dep := range deps {
		policy, policyErr := commonSvc.ResolveFifoPendingPolicy(ctx, tx, commonSvc.FifoPendingPolicyInput{
			Lane:          "USABLE",
			FlagGroupCode: dep.FlagGroupCode,
			FunctionCode:  dep.FunctionCode,
			LegacyTypeKey: dep.UsableType,
		})
		if policyErr != nil {
			s.Log.Errorf("Failed to resolve FIFO pending policy for purchase dependency: %+v", policyErr)
			return nil, false, utils.Internal("Failed to read FIFO v2 configuration")
		}
		if !policy.Found || !policy.AllowPending {
			allowPending = false
			break
		}
	}

	return deps, allowPending, nil
}

func (s *purchaseService) loadPurchaseDownstreamDependencies(
	ctx context.Context,
	tx *gorm.DB,
	itemIDs []uint,
) ([]purchaseDownstreamDependency, error) {
	if len(itemIDs) == 0 {
		return nil, nil
	}

	db := s.PurchaseRepo.DB().WithContext(ctx)
	if tx != nil {
		db = tx.WithContext(ctx)
	}

	var rows []purchaseDownstreamDependency
	err := db.Table("stock_allocations").
		Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
		Where("stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
		Where("stockable_id IN ?", itemIDs).
		Where("status = ?", entity.StockAllocationStatusActive).
		Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
		Where("deleted_at IS NULL").
		Group("usable_type, usable_id, function_code, flag_group_code").
		Scan(&rows).Error
	if err != nil {
		return nil, err
	}

	return rows, nil
}

func formatPurchaseDependencySummary(rows []purchaseDownstreamDependency) string {
	if len(rows) == 0 {
		return "-"
	}

	dependencyMap := make(map[string]map[uint64]struct{})
	for _, row := range rows {
		label := strings.ToUpper(strings.TrimSpace(row.UsableType))
		if label == "" {
			label = "UNKNOWN"
		}
		if _, ok := dependencyMap[label]; !ok {
			dependencyMap[label] = make(map[uint64]struct{})
		}
		dependencyMap[label][row.UsableID] = struct{}{}
	}

	labels := make([]string, 0, len(dependencyMap))
	for label := range dependencyMap {
		labels = append(labels, label)
	}
	sort.Strings(labels)

	parts := make([]string, 0, len(labels))
	for _, label := range labels {
		ids := make([]uint64, 0, len(dependencyMap[label]))
		for id := range dependencyMap[label] {
			ids = append(ids, id)
		}
		sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })

		idParts := make([]string, 0, len(ids))
		for _, id := range ids {
			idParts = append(idParts, fmt.Sprintf("%d", id))
		}
		parts = append(parts, fmt.Sprintf("%s=%s", label, strings.Join(idParts, "|")))
	}

	return strings.Join(parts, ", ")
}

func isReceivingBelowUsedBlocked(item *entity.PurchaseItem, lockedIDs map[uint]struct{}) bool {
	if item == nil {
		return false
	}
	if !purchaseItemHasAnyFlag(item, []utils.FlagType{
		utils.FlagPullet,
		utils.FlagLayer,
		utils.FlagAyamAfkir,
		utils.FlagAyamCulling,
		utils.FlagAyamMati,
	}) {
		return false
	}
	if lockedIDs == nil {
		return true
	}
	_, locked := lockedIDs[item.Id]
	return locked
}

func purchaseItemHasAnyFlag(item *entity.PurchaseItem, flags []utils.FlagType) bool {
	if item == nil || item.Product == nil || len(flags) == 0 {
		return false
	}
	for _, flag := range flags {
		if purchaseItemHasFlag(item, flag) {
			return true
		}
	}
	return false
}

func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error {
	if item == nil || item.Id == 0 || s.ApprovalSvc == nil {
		return nil
	}

	latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(item.Id), func(db *gorm.DB) *gorm.DB {
		return db.Preload("ActionUser")
	})
	if err != nil {
		return err
	}

	item.LatestApproval = latest
	return nil
}

func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error {
	if len(items) == 0 || s.ApprovalSvc == nil {
		return nil
	}

	ids := make([]uint, 0, len(items))
	for i := range items {
		if items[i].Id == 0 {
			continue
		}
		ids = append(ids, items[i].Id)
	}

	if len(ids) == 0 {
		return nil
	}

	latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB {
		return db.Preload("ActionUser")
	})
	if err != nil {
		return err
	}

	for i := range items {
		items[i].LatestApproval = latestMap[items[i].Id]
	}

	return nil
}

func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) {
	var fromPtr *time.Time
	var toPtr *time.Time

	if strings.TrimSpace(fromStr) != "" {
		parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr))
		if err != nil {
			return nil, nil, errors.New("po_date_from must use format YYYY-MM-DD")
		}
		fromValue := parsed
		fromPtr = &fromValue
	}

	if strings.TrimSpace(toStr) != "" {
		parsed, err := utils.ParseDateString(strings.TrimSpace(toStr))
		if err != nil {
			return nil, nil, errors.New("po_date_to must use format YYYY-MM-DD")
		}
		nextDay := parsed.AddDate(0, 0, 1)
		toPtr = &nextDay
	}

	if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) {
		return nil, nil, errors.New("po_date_from must be earlier than po_date_to")
	}

	return fromPtr, toPtr, nil
}

func applyPurchaseProjectFlockFilter(db *gorm.DB, projectFlockID, projectFlockKandangID uint) *gorm.DB {
	if projectFlockID > 0 {
		db = db.Where(
			`EXISTS (
				SELECT 1
				FROM purchase_items pi
				LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id
				LEFT JOIN warehouses w ON w.id = pi.warehouse_id
				LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
				WHERE pi.purchase_id = purchases.id
					AND COALESCE(pfk_explicit.project_flock_id, pfk_active.project_flock_id) = ?
			)`,
			projectFlockID,
		)
	}

	if projectFlockKandangID > 0 {
		db = db.Where(
			`EXISTS (
				SELECT 1
				FROM purchase_items pi
				LEFT JOIN warehouses w ON w.id = pi.warehouse_id
				LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
				WHERE pi.purchase_id = purchases.id
					AND COALESCE(pi.project_flock_kandang_id, pfk_active.id) = ?
			)`,
			projectFlockKandangID,
		)
	}

	return db
}

func applyPurchaseApprovalStatusFilter(db *gorm.DB, approvalStatuses []string) *gorm.DB {
	if len(approvalStatuses) == 0 {
		return db
	}

	approvalConditions := make([]string, 0, len(approvalStatuses))
	approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3))
	approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
	for _, status := range approvalStatuses {
		if status == "" {
			continue
		}
		like := "%" + status + "%"
		approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
		approvalArgs = append(approvalArgs, like, like, status)
	}

	if len(approvalConditions) == 0 {
		return db
	}

	approvalClause := strings.Join(approvalConditions, " OR ")
	approvalQuery := fmt.Sprintf(
		`EXISTS (
			SELECT 1
			FROM approvals a
			WHERE a.approvable_type = ?
				AND a.approvable_id = purchases.id
				AND a.id = (
					SELECT a2.id
					FROM approvals a2
					WHERE a2.approvable_type = ?
						AND a2.approvable_id = purchases.id
					ORDER BY a2.action_at DESC, a2.id DESC
					LIMIT 1
				)
				AND (%s)
		)`,
		approvalClause,
	)

	return db.Where(approvalQuery, approvalArgs...)
}

func applyPurchaseSearchFilter(db *gorm.DB, search string) *gorm.DB {
	if search == "" {
		return db
	}

	like := "%" + search + "%"
	return db.Where(
		`(
			LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
			OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
			OR EXISTS (
				SELECT 1
				FROM suppliers s
				WHERE s.id = purchases.supplier_id
					AND LOWER(COALESCE(s.name, '')) LIKE ?
			)
			OR EXISTS (
				SELECT 1
				FROM users u
				WHERE u.id = purchases.created_by
					AND LOWER(COALESCE(u.name, '')) LIKE ?
			)
			OR EXISTS (
				SELECT 1
				FROM purchase_items pi
				JOIN products p ON p.id = pi.product_id
				WHERE pi.purchase_id = purchases.id
					AND LOWER(COALESCE(p.name, '')) LIKE ?
			)
			OR EXISTS (
				SELECT 1
				FROM purchase_items pi
				JOIN warehouses w ON w.id = pi.warehouse_id
				JOIN locations l ON l.id = w.location_id
				WHERE pi.purchase_id = purchases.id
					AND LOWER(COALESCE(l.name, '')) LIKE ?
			)
			OR EXISTS (
				SELECT 1
				FROM purchase_items pi
				LEFT JOIN project_flock_kandangs pfk_explicit ON pfk_explicit.id = pi.project_flock_kandang_id
				LEFT JOIN warehouses w ON w.id = pi.warehouse_id
				LEFT JOIN project_flock_kandangs pfk_active ON pfk_active.kandang_id = w.kandang_id AND pfk_active.closed_at IS NULL
				LEFT JOIN project_flocks pf ON pf.id = COALESCE(pfk_explicit.project_flock_id, pfk_active.project_flock_id)
				LEFT JOIN kandangs k ON k.id = COALESCE(pfk_explicit.kandang_id, pfk_active.kandang_id, w.kandang_id)
				WHERE pi.purchase_id = purchases.id
					AND (
						LOWER(COALESCE(pf.flock_name, '')) LIKE ? OR
						LOWER(COALESCE(k.name, '')) LIKE ?
					)
			)
			OR EXISTS (
				SELECT 1
				FROM purchase_items pi
				JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
				JOIN expenses e ON e.id = en.expense_id
				WHERE pi.purchase_id = purchases.id
					AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
			)
		)`,
		like,
		like,
		like,
		like,
		like,
		like,
		like,
		like,
		like,
	)
}

func normalizeApprovalStatusFilter(raw string) string {
	value := strings.ToLower(strings.TrimSpace(raw))
	switch value {
	case "disetujui":
		return "approved"
	case "ditolak":
		return "rejected"
	default:
		return value
	}
}

func parseUintCSVFilter(raw, fieldName string) ([]uint, error) {
	parts := strings.Split(raw, ",")
	result := make([]uint, 0, len(parts))
	seen := make(map[uint]struct{}, len(parts))

	for _, part := range parts {
		value := strings.TrimSpace(part)
		if value == "" {
			continue
		}
		num, err := strconv.ParseUint(value, 10, 64)
		if err != nil || num == 0 {
			return nil, fmt.Errorf("%s contains invalid value: %s", fieldName, value)
		}
		u := uint(num)
		if _, exists := seen[u]; exists {
			continue
		}
		seen[u] = struct{}{}
		result = append(result, u)
	}

	return result, nil
}

func parseStringCSVFilter(raw string) []string {
	parts := strings.Split(raw, ",")
	result := make([]string, 0, len(parts))
	seen := make(map[string]struct{}, len(parts))

	for _, part := range parts {
		value := strings.ToLower(strings.TrimSpace(part))
		if value == "" {
			continue
		}
		if _, exists := seen[value]; exists {
			continue
		}
		seen[value] = struct{}{}
		result = append(result, value)
	}

	return result
}

func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) {
	value := strings.ToUpper(strings.TrimSpace(raw))
	switch value {
	case string(entity.ApprovalActionApproved):
		return entity.ApprovalActionApproved, nil
	case string(entity.ApprovalActionRejected):
		return entity.ApprovalActionRejected, nil
	default:
		return "", utils.BadRequest("action must be APPROVED or REJECTED")
	}
}

func (s *purchaseService) rejectAndReload(
	c *fiber.Ctx,
	step approvalutils.ApprovalStep,
	purchaseID uint,
	actorID uint,
	notes *string,
) (*entity.Purchase, error) {

	if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil {
		return nil, err
	}
	updated, err := s.loadPurchase(c.Context(), purchaseID)
	if err != nil {
		return nil, err
	}
	s.invalidateDepreciationSnapshots(
		c.Context(),
		nil,
		collectPFKIDsFromPurchase(updated),
		resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()),
	)
	return updated, nil
}
func (s *purchaseService) loadPurchase(
	ctx context.Context,
	id uint,
) (*entity.Purchase, error) {
	purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, utils.NotFound("Purchase not found")
		}
		s.Log.Errorf("Failed to get purchase %d: %+v", id, err)
		return nil, utils.Internal("Failed to get purchase")
	}

	if err := s.attachLatestApproval(ctx, purchase); err != nil {
		s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
	}
	s.applyTravelDocumentURLs(ctx, purchase)

	return purchase, nil
}

func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase *entity.Purchase) {
	if purchase == nil || s.DocumentSvc == nil {
		return
	}

	for i := range purchase.Items {
		item := &purchase.Items[i]
		documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id))
		if err != nil {
			s.Log.Warnf("Unable to load travel documents for purchase item %d: %+v", item.Id, err)
		} else {
			var targetDoc *entity.Document
			for j := len(documents) - 1; j >= 0; j-- {
				if documents[j].Type == string(utils.DocumentTypePurchaseTravel) {
					targetDoc = &documents[j]
					break
				}
			}
			if targetDoc != nil {
				url, err := s.DocumentSvc.PresignURL(ctx, *targetDoc, 15*time.Minute)
				if err != nil {
					s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err)
				} else if url != "" {
					item.TravelNumberDocs = &url
					continue
				}
			}
		}

		path := item.TravelNumberDocs
		if path == nil || strings.TrimSpace(*path) == "" {
			continue
		}
		url, err := commonSvc.ResolveDocumentURL(ctx, s.DocumentSvc, *path, 15*time.Minute)
		if err != nil {
			s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err)
			continue
		}
		if url == "" {
			continue
		}
		item.TravelNumberDocs = &url
	}
}

func collectPurchaseItemIDs(items []entity.PurchaseItem) []uint {
	itemIDs := make([]uint, 0, len(items))
	for i := range items {
		if items[i].Id == 0 {
			continue
		}
		itemIDs = append(itemIDs, items[i].Id)
	}
	return itemIDs
}

func (s *purchaseService) resolveChickinLockedItemIDs(ctx context.Context, db *gorm.DB, items []entity.PurchaseItem) (map[uint]struct{}, error) {
	itemIDs := collectPurchaseItemIDs(items)
	return s.resolveChickinLockedItemIDsByItemID(ctx, db, itemIDs)
}

func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Context, db *gorm.DB, itemIDs []uint) (map[uint]struct{}, error) {
	locked := make(map[uint]struct{})
	if len(itemIDs) == 0 {
		return locked, nil
	}
	if db == nil {
		return nil, errors.New("database is required")
	}

	var allocationLockedIDs []uint
	if err := db.WithContext(ctx).
		Model(&entity.StockAllocation{}).
		Distinct("stockable_id").
		Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ? AND allocation_purpose = ?",
			fifo.StockableKeyPurchaseItems.String(),
			itemIDs,
			fifo.UsableKeyProjectChickin.String(),
			[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
			entity.StockAllocationPurposeConsume,
		).
		Pluck("stockable_id", &allocationLockedIDs).Error; err != nil {
		return nil, err
	}
	for _, itemID := range allocationLockedIDs {
		locked[itemID] = struct{}{}
	}

	var conversionLockedIDs []uint
	if err := db.WithContext(ctx).
		Table("purchase_items pi").
		Distinct("pi.id").
		Joins("JOIN project_chickins pc ON pc.product_warehouse_id = pi.product_warehouse_id AND pc.deleted_at IS NULL").
		Joins("JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id AND pfp.deleted_at IS NULL").
		Where("pi.id IN ?", itemIDs).
		Where("pi.project_flock_kandang_id IS NOT NULL").
		Where("pc.project_flock_kandang_id = pi.project_flock_kandang_id").
		Pluck("pi.id", &conversionLockedIDs).Error; err != nil {
		return nil, err
	}
	for _, itemID := range conversionLockedIDs {
		locked[itemID] = struct{}{}
	}

	return locked, nil
}

func collectPFKIDsFromPurchase(p *entity.Purchase) []uint {
	if p == nil {
		return nil
	}
	return collectPFKIDsFromPurchaseItems(p.Items)
}

func collectPFKIDsFromPurchaseItems(items []entity.PurchaseItem) []uint {
	seen := make(map[uint]struct{})
	ids := make([]uint, 0)

	for _, item := range items {
		if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 {
			continue
		}
		id := uint(*item.ProjectFlockKandangId)
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		ids = append(ids, id)
	}
	return ids
}

func resolvePurchaseDepreciationInvalidateDate(
	purchase *entity.Purchase,
	items []entity.PurchaseItem,
	fallback time.Time,
) time.Time {
	fromDate := time.Time{}
	if purchase != nil {
		fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, purchase.CreatedAt)
		if purchase.PoDate != nil {
			fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, *purchase.PoDate)
		}
	}
	for _, item := range items {
		if item.ReceivedDate == nil {
			continue
		}
		fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, *item.ReceivedDate)
	}
	if fromDate.IsZero() {
		fromDate = fallback
	}
	return fromDate
}

func (s *purchaseService) invalidateDepreciationSnapshots(
	ctx context.Context,
	tx *gorm.DB,
	projectFlockKandangIDs []uint,
	fromDate time.Time,
) {
	if fromDate.IsZero() {
		return
	}
	projectFlockKandangIDs = utils.UniqueUintSlice(projectFlockKandangIDs)

	targetDB := s.PurchaseRepo.DB()
	if tx != nil {
		targetDB = tx
	}

	var farmIDs []uint
	if len(projectFlockKandangIDs) > 0 {
		resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, projectFlockKandangIDs)
		if err != nil {
			s.Log.Warnf(
				"Failed to resolve farm ids for purchase depreciation invalidation (pfk_ids=%v): %+v",
				projectFlockKandangIDs,
				err,
			)
		} else {
			farmIDs = resolvedFarmIDs
		}
	}

	if len(farmIDs) == 0 {
		if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil {
			s.Log.Warnf(
				"Failed to invalidate depreciation snapshots globally (from=%s): %+v",
				fromDate.Format("2006-01-02"),
				err,
			)
		}
		return
	}

	if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil {
		s.Log.Warnf(
			"Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v",
			farmIDs,
			fromDate.Format("2006-01-02"),
			err,
		)
	}
}

func (s *purchaseService) ensureProjectFlockNotClosedForPurchase(
	ctx context.Context,
	purchase *entity.Purchase,
) error {
	pfkIDs := collectPFKIDsFromPurchase(purchase)
	if len(pfkIDs) == 0 {
		return nil
	}

	db := s.PurchaseRepo.DB()
	if db == nil {
		return utils.Internal("DB not available for project flock validation")
	}

	return commonSvc.EnsureProjectFlockNotClosedByProjectFlockKandangID(ctx, db, pfkIDs)
}
