mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
2714 lines
80 KiB
Go
2714 lines
80 KiB
Go
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)
|
|
UpdatePoDate(ctx *fiber.Ctx, id uint, req *validation.UpdatePoDateRequest) (*entity.Purchase, 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()
|
|
poDateToSet := now
|
|
if req.PoDate != nil && strings.TrimSpace(*req.PoDate) != "" {
|
|
if parsed, parseErr := utils.ParseDateString(strings.TrimSpace(*req.PoDate)); parseErr == nil {
|
|
poDateToSet = parsed.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"] = poDateToSet
|
|
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 = &poDateToSet
|
|
}
|
|
|
|
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) UpdatePoDate(c *fiber.Ctx, id uint, req *validation.UpdatePoDateRequest) (*entity.Purchase, error) {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parsed, err := utils.ParseDateString(strings.TrimSpace(req.PoDate))
|
|
if err != nil {
|
|
return nil, utils.BadRequest("po_date must use format YYYY-MM-DD")
|
|
}
|
|
poDate := parsed.UTC()
|
|
|
|
purchase, err := s.loadPurchase(c.Context(), id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.PurchaseRepo.PatchOne(c.Context(), id, map[string]any{"po_date": poDate}, nil); err != nil {
|
|
return nil, utils.Internal("Failed to update po_date")
|
|
}
|
|
|
|
purchase.PoDate = &poDate
|
|
|
|
updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
|
|
if err != nil {
|
|
return nil, utils.Internal("Failed to reload 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)
|
|
}
|
|
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 = ©
|
|
}
|
|
|
|
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)
|
|
}
|