mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-23 14:55:42 +00:00
Merge branch 'feat/BE/US-278/Purchase-Expedition-bop' into 'feat/BE/Sprint-6'
feat/BE/US-278/TASK-288,289-adjust schema database,Create trigger in expense... See merge request mbugroup/lti-api!84
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE purchase_items
|
||||||
|
DROP CONSTRAINT fk_purchase_items_expense_nonstock;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE purchase_items
|
||||||
|
DROP CONSTRAINT fk_purchase_items_project_flock_kandang;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_purchase_items_expense_nonstock_id;
|
||||||
|
DROP INDEX IF EXISTS idx_purchase_items_project_flock_kandang_id;
|
||||||
|
|
||||||
|
ALTER TABLE purchase_items
|
||||||
|
DROP COLUMN IF EXISTS expense_nonstock_id,
|
||||||
|
DROP COLUMN IF EXISTS project_flock_kandang_id,
|
||||||
|
ALTER COLUMN vehicle_number DROP NOT NULL,
|
||||||
|
ALTER COLUMN vehicle_number TYPE VARCHAR USING vehicle_number;
|
||||||
|
|
||||||
|
ALTER TABLE purchases
|
||||||
|
ALTER COLUMN pr_number TYPE VARCHAR USING pr_number,
|
||||||
|
ALTER COLUMN po_number TYPE VARCHAR USING po_number,
|
||||||
|
ALTER COLUMN created_at DROP DEFAULT,
|
||||||
|
ALTER COLUMN updated_at DROP DEFAULT;
|
||||||
|
|
||||||
|
ALTER TABLE purchases
|
||||||
|
ADD COLUMN credit_term INT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE purchases
|
||||||
|
ALTER COLUMN credit_term DROP DEFAULT,
|
||||||
|
ALTER COLUMN grand_total DROP DEFAULT;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Adjust purchases table to new purchasing schema
|
||||||
|
ALTER TABLE purchases
|
||||||
|
ALTER COLUMN pr_number TYPE VARCHAR(50) USING LEFT(pr_number, 50),
|
||||||
|
ALTER COLUMN po_number TYPE VARCHAR(50) USING LEFT(po_number, 50),
|
||||||
|
ALTER COLUMN created_at SET DEFAULT now(),
|
||||||
|
ALTER COLUMN updated_at SET DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE purchases
|
||||||
|
DROP COLUMN IF EXISTS credit_term,
|
||||||
|
DROP COLUMN IF EXISTS grand_total;
|
||||||
|
|
||||||
|
-- Bring purchase_items in line with new requirements
|
||||||
|
ALTER TABLE purchase_items
|
||||||
|
ADD COLUMN IF NOT EXISTS expense_nonstock_id BIGINT,
|
||||||
|
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT;
|
||||||
|
|
||||||
|
UPDATE purchase_items
|
||||||
|
SET vehicle_number = ''
|
||||||
|
WHERE vehicle_number IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE purchase_items
|
||||||
|
ALTER COLUMN vehicle_number TYPE VARCHAR(10) USING LEFT(vehicle_number, 10),
|
||||||
|
ALTER COLUMN vehicle_number SET NOT NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expense_nonstocks') THEN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock'
|
||||||
|
) THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE purchase_items
|
||||||
|
ADD CONSTRAINT fk_purchase_items_expense_nonstock
|
||||||
|
FOREIGN KEY (expense_nonstock_id)
|
||||||
|
REFERENCES expense_nonstocks(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang'
|
||||||
|
) THEN
|
||||||
|
EXECUTE
|
||||||
|
'ALTER TABLE purchase_items
|
||||||
|
ADD CONSTRAINT fk_purchase_items_project_flock_kandang
|
||||||
|
FOREIGN KEY (project_flock_kandang_id)
|
||||||
|
REFERENCES project_flock_kandangs(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_items_expense_nonstock_id
|
||||||
|
ON purchase_items (expense_nonstock_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_items_project_flock_kandang_id
|
||||||
|
ON purchase_items (project_flock_kandang_id);
|
||||||
@@ -10,9 +10,7 @@ type Purchase struct {
|
|||||||
PoNumber *string
|
PoNumber *string
|
||||||
PoDate *time.Time
|
PoDate *time.Time
|
||||||
SupplierId uint `gorm:"not null"`
|
SupplierId uint `gorm:"not null"`
|
||||||
CreditTerm *int
|
|
||||||
DueDate *time.Time
|
DueDate *time.Time
|
||||||
GrandTotal float64 `gorm:"type:numeric(15,3);default:0"`
|
|
||||||
Notes *string
|
Notes *string
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||||
|
|||||||
@@ -5,20 +5,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PurchaseItem struct {
|
type PurchaseItem struct {
|
||||||
Id uint `gorm:"primaryKey;autoIncrement"`
|
Id uint `gorm:"primaryKey;autoIncrement"`
|
||||||
PurchaseId uint `gorm:"not null"`
|
PurchaseId uint `gorm:"not null"`
|
||||||
ProductId uint `gorm:"not null"`
|
ProductId uint `gorm:"not null"`
|
||||||
WarehouseId uint `gorm:"not null"`
|
WarehouseId uint `gorm:"not null"`
|
||||||
ProductWarehouseId *uint
|
ProductWarehouseId *uint
|
||||||
ReceivedDate *time.Time
|
ProjectFlockKandangId *uint
|
||||||
TravelNumber *string
|
ReceivedDate *time.Time
|
||||||
TravelNumberDocs *string
|
TravelNumber *string
|
||||||
VehicleNumber *string
|
TravelNumberDocs *string
|
||||||
SubQty float64 `gorm:"type:numeric(15,3);not null"`
|
VehicleNumber *string
|
||||||
TotalQty float64 `gorm:"type:numeric(15,3);default:0"`
|
SubQty float64 `gorm:"type:numeric(15,3);not null"`
|
||||||
TotalUsed float64 `gorm:"type:numeric(15,3);default:0"`
|
TotalQty float64 `gorm:"type:numeric(15,3);default:0"`
|
||||||
Price float64 `gorm:"type:numeric(15,3);default:0"`
|
TotalUsed float64 `gorm:"type:numeric(15,3);default:0"`
|
||||||
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"`
|
Price float64 `gorm:"type:numeric(15,3);default:0"`
|
||||||
|
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"`
|
||||||
|
ExpenseNonstockId *uint64
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"`
|
Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"`
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
|
|||||||
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
|
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
|
||||||
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction)
|
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction)
|
||||||
|
|
||||||
referenceNumber, err := s.generateReferenceNumber(dbTransaction)
|
referenceNumber, err := GenerateExpenseReferenceNumber(c.Context(), expenseRepoTx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number")
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number")
|
||||||
}
|
}
|
||||||
@@ -1056,17 +1056,6 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *expenseService) generateReferenceNumber(ctx *gorm.DB) (string, error) {
|
|
||||||
|
|
||||||
sequence, err := s.Repository.GetNextSequence(ctx.Statement.Context)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
refNum := fmt.Sprintf("BOP-LTI-%05d", sequence)
|
|
||||||
|
|
||||||
return refNum, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) {
|
func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) {
|
||||||
|
|
||||||
expenseRepoTx := repository.NewExpenseRepository(ctx)
|
expenseRepoTx := repository.NewExpenseRepository(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateExpenseReferenceNumber builds a new reference number using the expense sequence.
|
||||||
|
func GenerateExpenseReferenceNumber(ctx context.Context, repo expenseRepo.ExpenseRepository) (string, error) {
|
||||||
|
sequence, err := repo.GetNextSequence(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("BOP-LTI-%05d", sequence), nil
|
||||||
|
}
|
||||||
@@ -24,10 +24,11 @@ func NewWarehouseController(warehouseService service.WarehouseService) *Warehous
|
|||||||
|
|
||||||
func (u *WarehouseController) GetAll(c *fiber.Ctx) error {
|
func (u *WarehouseController) GetAll(c *fiber.Ctx) error {
|
||||||
query := &validation.Query{
|
query := &validation.Query{
|
||||||
Page: c.QueryInt("page", 1),
|
Page: c.QueryInt("page", 1),
|
||||||
Limit: c.QueryInt("limit", 10),
|
Limit: c.QueryInt("limit", 10),
|
||||||
Search: c.Query("search", ""),
|
Search: c.Query("search", ""),
|
||||||
AreaId: c.QueryInt("area_id", 0),
|
AreaId: c.QueryInt("area_id", 0),
|
||||||
|
ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false),
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.Page < 1 || query.Limit < 1 {
|
if query.Page < 1 || query.Limit < 1 {
|
||||||
|
|||||||
@@ -53,11 +53,28 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
|||||||
warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
||||||
db = s.withRelations(db)
|
db = s.withRelations(db)
|
||||||
if params.Search != "" {
|
if params.Search != "" {
|
||||||
return db.Where("name LIKE ?", "%"+params.Search+"%")
|
db = db.Where("warehouses.name LIKE ?", "%"+params.Search+"%")
|
||||||
}
|
}
|
||||||
if params.AreaId != 0 {
|
if params.AreaId != 0 {
|
||||||
db = db.Where("area_id = ?", params.AreaId)
|
db = db.Where("area_id = ?", params.AreaId)
|
||||||
}
|
}
|
||||||
|
if params.ActiveProjectFlockOnly {
|
||||||
|
db = db.Where(`
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM kandangs k
|
||||||
|
JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id
|
||||||
|
JOIN (
|
||||||
|
SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at
|
||||||
|
FROM approvals
|
||||||
|
WHERE approvable_type = 'PROJECT_FLOCKS'
|
||||||
|
ORDER BY approvable_id, action_at DESC
|
||||||
|
) latest_approval ON latest_approval.approvable_id = pfk.project_flock_id
|
||||||
|
WHERE k.id = warehouses.kandang_id
|
||||||
|
AND LOWER(latest_approval.step_name) = LOWER(?)
|
||||||
|
)
|
||||||
|
`, "Aktif")
|
||||||
|
}
|
||||||
return db.Order("created_at DESC").Order("updated_at DESC")
|
return db.Order("created_at DESC").Order("updated_at DESC")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ type Update struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Query struct {
|
type Query struct {
|
||||||
Page int `query:"page" validate:"omitempty,number,min=1"`
|
Page int `query:"page" validate:"omitempty,number,min=1"`
|
||||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
||||||
Search string `query:"search" validate:"omitempty,max=50"`
|
Search string `query:"search" validate:"omitempty,max=50"`
|
||||||
AreaId int `query:"area_id" validate:"omitempty,number,gt=0"`
|
AreaId int `query:"area_id" validate:"omitempty,number,gt=0"`
|
||||||
|
ActiveProjectFlockOnly bool `query:"active_project_flock"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
|||||||
ProductWarehouseID: "product_warehouse_id",
|
ProductWarehouseID: "product_warehouse_id",
|
||||||
UsageQuantity: "usage_qty",
|
UsageQuantity: "usage_qty",
|
||||||
PendingQuantity: "pending_qty",
|
PendingQuantity: "pending_qty",
|
||||||
CreatedAt: "created_at",
|
CreatedAt: "id",
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
|
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
|
||||||
|
|||||||
@@ -21,13 +21,10 @@ type PurchaseRelationDTO struct {
|
|||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type PurchaseListDTO struct {
|
type PurchaseListDTO struct {
|
||||||
PurchaseRelationDTO
|
PurchaseRelationDTO
|
||||||
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
|
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
|
||||||
CreditTerm *int `json:"credit_term"`
|
|
||||||
DueDate *time.Time `json:"due_date"`
|
DueDate *time.Time `json:"due_date"`
|
||||||
GrandTotal float64 `json:"grand_total"`
|
|
||||||
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
|
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -37,9 +34,7 @@ type PurchaseListDTO struct {
|
|||||||
type PurchaseDetailDTO struct {
|
type PurchaseDetailDTO struct {
|
||||||
PurchaseRelationDTO
|
PurchaseRelationDTO
|
||||||
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
|
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
|
||||||
CreditTerm *int `json:"credit_term"`
|
|
||||||
DueDate *time.Time `json:"due_date"`
|
DueDate *time.Time `json:"due_date"`
|
||||||
GrandTotal float64 `json:"grand_total"`
|
|
||||||
Items []PurchaseItemDTO `json:"items"`
|
Items []PurchaseItemDTO `json:"items"`
|
||||||
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
|
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -145,9 +140,7 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
|
|||||||
return PurchaseListDTO{
|
return PurchaseListDTO{
|
||||||
PurchaseRelationDTO: ToPurchaseRelationDTO(&p),
|
PurchaseRelationDTO: ToPurchaseRelationDTO(&p),
|
||||||
Supplier: supplier,
|
Supplier: supplier,
|
||||||
CreditTerm: p.CreditTerm,
|
|
||||||
DueDate: p.DueDate,
|
DueDate: p.DueDate,
|
||||||
GrandTotal: p.GrandTotal,
|
|
||||||
CreatedUser: createdUser,
|
CreatedUser: createdUser,
|
||||||
CreatedAt: p.CreatedAt,
|
CreatedAt: p.CreatedAt,
|
||||||
UpdatedAt: p.UpdatedAt,
|
UpdatedAt: p.UpdatedAt,
|
||||||
@@ -188,13 +181,11 @@ func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO {
|
|||||||
return PurchaseDetailDTO{
|
return PurchaseDetailDTO{
|
||||||
PurchaseRelationDTO: ToPurchaseRelationDTO(&p),
|
PurchaseRelationDTO: ToPurchaseRelationDTO(&p),
|
||||||
Supplier: supplier,
|
Supplier: supplier,
|
||||||
CreditTerm: p.CreditTerm,
|
|
||||||
DueDate: p.DueDate,
|
DueDate: p.DueDate,
|
||||||
GrandTotal: p.GrandTotal,
|
|
||||||
Items: ToPurchaseItemDTOs(p.Items),
|
Items: ToPurchaseItemDTOs(p.Items),
|
||||||
CreatedUser: createdUser,
|
CreatedUser: createdUser,
|
||||||
CreatedAt: p.CreatedAt,
|
CreatedAt: p.CreatedAt,
|
||||||
UpdatedAt: p.UpdatedAt,
|
UpdatedAt: p.UpdatedAt,
|
||||||
LatestApproval: latestApproval,
|
LatestApproval: latestApproval,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,20 @@ import (
|
|||||||
|
|
||||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
|
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
|
||||||
|
expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
|
||||||
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
||||||
|
rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories"
|
||||||
rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/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"
|
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
|
||||||
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/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"
|
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
|
||||||
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
|
service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services"
|
||||||
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||||
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
||||||
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
|
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,13 +33,49 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
|||||||
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
|
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
|
||||||
supplierRepo := rSupplier.NewSupplierRepository(db)
|
supplierRepo := rSupplier.NewSupplierRepository(db)
|
||||||
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
|
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
|
||||||
|
nonstockRepo := rNonstock.NewNonstockRepository(db)
|
||||||
|
expenseRepository := expenseRepo.NewExpenseRepository(db)
|
||||||
|
expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db)
|
||||||
|
projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
|
||||||
|
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
|
||||||
|
|
||||||
approvalRepo := commonRepo.NewApprovalRepository(db)
|
approvalRepo := commonRepo.NewApprovalRepository(db)
|
||||||
approvalService := commonSvc.NewApprovalService(approvalRepo)
|
approvalService := commonSvc.NewApprovalService(approvalRepo)
|
||||||
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil {
|
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil {
|
||||||
panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err))
|
panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err))
|
||||||
}
|
}
|
||||||
expenseBridge := service.NewNoopPurchaseExpenseBridge()
|
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to register expense approval workflow: %v", err))
|
||||||
|
}
|
||||||
|
expenseServiceInstance := expenseService.NewExpenseService(
|
||||||
|
expenseRepository,
|
||||||
|
supplierRepo,
|
||||||
|
nonstockRepo,
|
||||||
|
approvalService,
|
||||||
|
expenseRealizationRepo,
|
||||||
|
projectFlockKandangRepository,
|
||||||
|
validate,
|
||||||
|
)
|
||||||
|
expenseBridge := service.NewExpenseBridge(
|
||||||
|
db,
|
||||||
|
purchaseRepo,
|
||||||
|
projectFlockKandangRepository,
|
||||||
|
expenseServiceInstance,
|
||||||
|
)
|
||||||
|
|
||||||
|
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
|
||||||
|
_ = fifoService.RegisterStockable(fifo.StockableConfig{
|
||||||
|
Key: fifo.StockableKey("PURCHASE_ITEMS"),
|
||||||
|
Table: "purchase_items",
|
||||||
|
Columns: fifo.StockableColumns{
|
||||||
|
ID: "id",
|
||||||
|
ProductWarehouseID: "product_warehouse_id",
|
||||||
|
TotalQuantity: "total_qty",
|
||||||
|
TotalUsedQuantity: "total_used",
|
||||||
|
CreatedAt: "id",
|
||||||
|
},
|
||||||
|
OrderBy: []string{"id ASC"},
|
||||||
|
})
|
||||||
|
|
||||||
purchaseService := service.NewPurchaseService(
|
purchaseService := service.NewPurchaseService(
|
||||||
validate,
|
validate,
|
||||||
@@ -43,8 +84,10 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
|||||||
warehouseRepo,
|
warehouseRepo,
|
||||||
supplierRepo,
|
supplierRepo,
|
||||||
productWarehouseRepo,
|
productWarehouseRepo,
|
||||||
|
projectFlockKandangRepository,
|
||||||
approvalService,
|
approvalService,
|
||||||
expenseBridge,
|
expenseBridge,
|
||||||
|
fifoService,
|
||||||
)
|
)
|
||||||
|
|
||||||
userRepo := rUser.NewUserRepository(db)
|
userRepo := rUser.NewUserRepository(db)
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ type PurchaseRepository interface {
|
|||||||
repository.BaseRepository[entity.Purchase]
|
repository.BaseRepository[entity.Purchase]
|
||||||
CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error
|
CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error
|
||||||
CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error
|
CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error
|
||||||
UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate, grandTotal float64) error
|
UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate) error
|
||||||
UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error
|
UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error
|
||||||
DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error
|
DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error
|
||||||
UpdateGrandTotal(ctx context.Context, purchaseID uint, grandTotal float64) error
|
|
||||||
NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error)
|
NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error)
|
||||||
NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error)
|
NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error)
|
||||||
|
BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchaseRepositoryImpl struct {
|
type PurchaseRepositoryImpl struct {
|
||||||
@@ -59,6 +59,34 @@ func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase *
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PurchaseRepositoryImpl) BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error {
|
||||||
|
if purchaseID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
WITH latest_pfk AS (
|
||||||
|
SELECT pfk.id, pfk.kandang_id
|
||||||
|
FROM project_flock_kandangs pfk
|
||||||
|
JOIN (
|
||||||
|
SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at
|
||||||
|
FROM approvals
|
||||||
|
WHERE approvable_type = 'PROJECT_FLOCKS'
|
||||||
|
ORDER BY approvable_id, action_at DESC
|
||||||
|
) latest_approval ON latest_approval.approvable_id = pfk.project_flock_id
|
||||||
|
WHERE LOWER(latest_approval.step_name) = LOWER('Aktif')
|
||||||
|
)
|
||||||
|
UPDATE purchase_items pi
|
||||||
|
SET project_flock_kandang_id = lp.id
|
||||||
|
FROM warehouses w
|
||||||
|
JOIN latest_pfk lp ON lp.kandang_id = w.kandang_id
|
||||||
|
WHERE pi.purchase_id = ?
|
||||||
|
AND pi.project_flock_kandang_id IS NULL
|
||||||
|
AND pi.warehouse_id = w.id;
|
||||||
|
`
|
||||||
|
return r.DB().WithContext(ctx).Exec(query, purchaseID).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error {
|
func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -99,7 +127,6 @@ func (r *PurchaseRepositoryImpl) UpdatePricing(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
purchaseID uint,
|
purchaseID uint,
|
||||||
updates []PurchasePricingUpdate,
|
updates []PurchasePricingUpdate,
|
||||||
grandTotal float64,
|
|
||||||
) error {
|
) error {
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
return errors.New("pricing updates cannot be empty")
|
return errors.New("pricing updates cannot be empty")
|
||||||
@@ -133,14 +160,6 @@ func (r *PurchaseRepositoryImpl) UpdatePricing(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Model(&entity.Purchase{}).
|
|
||||||
Where("id = ?", purchaseID).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"grand_total": grandTotal,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,20 +220,6 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PurchaseRepositoryImpl) UpdateGrandTotal(
|
|
||||||
ctx context.Context,
|
|
||||||
purchaseID uint,
|
|
||||||
grandTotal float64,
|
|
||||||
) error {
|
|
||||||
return r.DB().WithContext(ctx).
|
|
||||||
Model(&entity.Purchase{}).
|
|
||||||
Where("id = ?", purchaseID).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"grand_total": grandTotal,
|
|
||||||
"updated_at": gorm.Expr("NOW()"),
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error {
|
func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error {
|
||||||
if len(itemIDs) == 0 {
|
if len(itemIDs) == 0 {
|
||||||
return errors.New("itemIDs cannot be empty")
|
return errors.New("itemIDs cannot be empty")
|
||||||
|
|||||||
@@ -2,42 +2,757 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
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"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
|
||||||
|
expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
|
||||||
|
expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
|
||||||
|
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
||||||
|
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PurchaseExpenseBridge defines hooks that allow purchase flows to stay in sync with expense data once it exists.
|
|
||||||
type PurchaseExpenseBridge interface {
|
type PurchaseExpenseBridge interface {
|
||||||
OnItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error
|
OnItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error
|
||||||
OnItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) error
|
OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error
|
||||||
OnItemsReceived(ctx context.Context, purchaseID uint, updates []ExpenseReceivingPayload) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpenseReceivingPayload captures the minimum data expense integration will need once available.
|
|
||||||
type ExpenseReceivingPayload struct {
|
type ExpenseReceivingPayload struct {
|
||||||
PurchaseItemID uint
|
PurchaseItemID uint
|
||||||
ProductID uint
|
ProductID uint
|
||||||
WarehouseID uint
|
WarehouseID uint
|
||||||
ReceivedQty float64
|
SupplierID uint
|
||||||
ReceivedDate *time.Time
|
TransportPerItem *float64
|
||||||
|
ReceivedQty float64
|
||||||
|
ReceivedDate *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// noopPurchaseExpenseBridge is the default implementation until the expense module is ready.
|
type groupedItem struct {
|
||||||
type noopPurchaseExpenseBridge struct{}
|
item *entity.PurchaseItem
|
||||||
|
payload ExpenseReceivingPayload
|
||||||
func NewNoopPurchaseExpenseBridge() PurchaseExpenseBridge {
|
projectFK *uint
|
||||||
return &noopPurchaseExpenseBridge{}
|
kandangID *uint
|
||||||
|
totalPrice float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *noopPurchaseExpenseBridge) OnItemsCreated(_ context.Context, _ uint, _ []entity.PurchaseItem) error {
|
func groupingKey(supplierID uint, date time.Time, warehouseID uint) string {
|
||||||
|
return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type expenseBridge struct {
|
||||||
|
db *gorm.DB
|
||||||
|
purchaseRepo rPurchase.PurchaseRepository
|
||||||
|
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
|
||||||
|
expenseSvc expenseSvc.ExpenseService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExpenseBridge(
|
||||||
|
db *gorm.DB,
|
||||||
|
purchaseRepo rPurchase.PurchaseRepository,
|
||||||
|
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
|
||||||
|
expenseSvc expenseSvc.ExpenseService,
|
||||||
|
) PurchaseExpenseBridge {
|
||||||
|
return &expenseBridge{
|
||||||
|
db: db,
|
||||||
|
purchaseRepo: purchaseRepo,
|
||||||
|
projectFlockKandangRepo: projectFlockKandangRepo,
|
||||||
|
expenseSvc: expenseSvc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *expenseBridge) OnItemsDeleted(ctx context.Context, _ uint, items []entity.PurchaseItem) error {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
expenseIDs := make(map[uint64]struct{})
|
||||||
|
expenseNonstockIDs := make([]uint64, 0)
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 {
|
||||||
|
expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(expenseNonstockIDs) > 0 {
|
||||||
|
for _, nsID := range expenseNonstockIDs {
|
||||||
|
var expenseID uint64
|
||||||
|
if err := tx.
|
||||||
|
Model(&entity.ExpenseNonstock{}).
|
||||||
|
Select("expense_id").
|
||||||
|
Where("id = ?", nsID).
|
||||||
|
Scan(&expenseID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if expenseID != 0 {
|
||||||
|
expenseIDs[expenseID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var links []struct {
|
||||||
|
ItemID uint
|
||||||
|
ExpenseNonstockID *uint64
|
||||||
|
}
|
||||||
|
if err := tx.
|
||||||
|
Model(&entity.PurchaseItem{}).
|
||||||
|
Select("id as item_id, expense_nonstock_id").
|
||||||
|
Where("id IN ?", extractIDs(items)).
|
||||||
|
Scan(&links).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, link := range links {
|
||||||
|
if link.ExpenseNonstockID == nil || *link.ExpenseNonstockID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var expenseID uint64
|
||||||
|
if err := tx.
|
||||||
|
Model(&entity.ExpenseNonstock{}).
|
||||||
|
Select("expense_id").
|
||||||
|
Where("id = ?", *link.ExpenseNonstockID).
|
||||||
|
Scan(&expenseID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if expenseID != 0 {
|
||||||
|
expenseIDs[expenseID] = struct{}{}
|
||||||
|
}
|
||||||
|
if err := tx.Delete(&entity.ExpenseNonstock{}, *link.ExpenseNonstockID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
|
||||||
|
for expenseID := range expenseIDs {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&entity.ExpenseNonstock{}).
|
||||||
|
Where("expense_id = ?", expenseID).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *expenseBridge) cleanupExistingNonstocks(ctx context.Context, updates []ExpenseReceivingPayload) error {
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
itemIDs := make([]uint, 0, len(updates))
|
||||||
|
for _, upd := range updates {
|
||||||
|
if upd.PurchaseItemID != 0 {
|
||||||
|
itemIDs = append(itemIDs, upd.PurchaseItemID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(itemIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var links []struct {
|
||||||
|
ItemID uint
|
||||||
|
ExpenseNonstockID *uint64
|
||||||
|
}
|
||||||
|
if err := tx.Model(&entity.PurchaseItem{}).
|
||||||
|
Select("id as item_id, expense_nonstock_id").
|
||||||
|
Where("id IN ?", itemIDs).
|
||||||
|
Scan(&links).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
expenseIDs := make(map[uint64]struct{})
|
||||||
|
expenseNonstockIDs := make([]uint64, 0)
|
||||||
|
for _, link := range links {
|
||||||
|
if link.ExpenseNonstockID != nil && *link.ExpenseNonstockID != 0 {
|
||||||
|
expenseNonstockIDs = append(expenseNonstockIDs, *link.ExpenseNonstockID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(expenseNonstockIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nsID := range expenseNonstockIDs {
|
||||||
|
var expenseID uint64
|
||||||
|
if err := tx.Model(&entity.ExpenseNonstock{}).
|
||||||
|
Select("expense_id").
|
||||||
|
Where("id = ?", nsID).
|
||||||
|
Scan(&expenseID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if expenseID != 0 {
|
||||||
|
expenseIDs[expenseID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
|
||||||
|
for expenseID := range expenseIDs {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&entity.ExpenseNonstock{}).
|
||||||
|
Where("expense_id = ?", expenseID).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *expenseBridge) cleanupEmptyExpenses(ctx context.Context, expenseIDs []uint64) error {
|
||||||
|
if len(expenseIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
|
||||||
|
for _, id := range expenseIDs {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&entity.ExpenseNonstock{}).
|
||||||
|
Where("expense_id = ?", id).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Delete(&entity.Expense{}, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error {
|
||||||
|
if len(expenseIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if actorID == 0 {
|
||||||
|
actorID = 1
|
||||||
|
}
|
||||||
|
svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db))
|
||||||
|
action := entity.ApprovalActionUpdated
|
||||||
|
for id := range expenseIDs {
|
||||||
|
if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *noopPurchaseExpenseBridge) OnItemsDeleted(_ context.Context, _ uint, _ []uint) error {
|
func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error {
|
||||||
|
if purchaseID == 0 || len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Context()
|
||||||
|
|
||||||
|
// Load current links to decide whether to update in place or recreate.
|
||||||
|
type itemLink struct {
|
||||||
|
ExpenseNonstockID uint64
|
||||||
|
ExpenseID uint64
|
||||||
|
SupplierID uint
|
||||||
|
TransactionDate time.Time
|
||||||
|
Qty float64
|
||||||
|
Price float64
|
||||||
|
}
|
||||||
|
|
||||||
|
purchase, err := b.purchaseRepo.GetByID(ctx, purchaseID, func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.
|
||||||
|
Preload("Items").
|
||||||
|
Preload("Items.Warehouse").
|
||||||
|
Preload("Items.Warehouse.Kandang")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
itemLinks := make(map[uint]itemLink)
|
||||||
|
updatedExpenses := make(map[uint64]struct{})
|
||||||
|
if len(updates) > 0 {
|
||||||
|
ids := make([]uint, 0, len(updates))
|
||||||
|
for _, upd := range updates {
|
||||||
|
if upd.PurchaseItemID != 0 {
|
||||||
|
ids = append(ids, upd.PurchaseItemID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
rows := make([]struct {
|
||||||
|
ItemID uint
|
||||||
|
ExpenseNonstockID uint64
|
||||||
|
ExpenseID uint64
|
||||||
|
SupplierID uint
|
||||||
|
TransactionDate time.Time
|
||||||
|
Qty float64
|
||||||
|
Price float64
|
||||||
|
}, 0)
|
||||||
|
if err := b.db.WithContext(ctx).
|
||||||
|
Table("purchase_items pi").
|
||||||
|
Select("pi.id as item_id, en.id as expense_nonstock_id, en.expense_id, e.supplier_id, e.transaction_date, en.qty, en.price").
|
||||||
|
Joins("LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id").
|
||||||
|
Joins("LEFT JOIN expenses e ON e.id = en.expense_id").
|
||||||
|
Where("pi.id IN ?", ids).
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Build quick lookup per item and per group key for existing expenses.
|
||||||
|
for _, row := range rows {
|
||||||
|
itemLinks[row.ItemID] = itemLink{
|
||||||
|
ExpenseNonstockID: row.ExpenseNonstockID,
|
||||||
|
ExpenseID: row.ExpenseID,
|
||||||
|
SupplierID: row.SupplierID,
|
||||||
|
TransactionDate: row.TransactionDate,
|
||||||
|
Qty: row.Qty,
|
||||||
|
Price: row.Price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items))
|
||||||
|
for i := range purchase.Items {
|
||||||
|
itemMap[purchase.Items[i].Id] = &purchase.Items[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make(map[string][]groupedItem)
|
||||||
|
|
||||||
|
for _, payload := range updates {
|
||||||
|
if payload.ReceivedDate == nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "received_date is required")
|
||||||
|
}
|
||||||
|
item := itemMap[payload.PurchaseItemID]
|
||||||
|
if item == nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID))
|
||||||
|
}
|
||||||
|
if payload.ReceivedQty <= 0 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d must be greater than 0", payload.PurchaseItemID))
|
||||||
|
}
|
||||||
|
|
||||||
|
receivedDate := payload.ReceivedDate.UTC().Truncate(24 * time.Hour)
|
||||||
|
supplierID := payload.SupplierID
|
||||||
|
if supplierID == 0 {
|
||||||
|
supplierID = purchase.SupplierId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide whether to update existing expense_nonstock or create new.
|
||||||
|
link, hasLink := itemLinks[payload.PurchaseItemID]
|
||||||
|
if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 {
|
||||||
|
oldDate := link.TransactionDate.UTC().Truncate(24 * time.Hour)
|
||||||
|
newDate := receivedDate
|
||||||
|
oldSupplier := link.SupplierID
|
||||||
|
pricePerItem := item.Price
|
||||||
|
if payload.TransportPerItem != nil {
|
||||||
|
pricePerItem = *payload.TransportPerItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// If supplier/date unchanged, update nonstock in place.
|
||||||
|
if oldSupplier == supplierID && oldDate.Equal(newDate) {
|
||||||
|
note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID)
|
||||||
|
if err := b.db.WithContext(ctx).
|
||||||
|
Model(&entity.ExpenseNonstock{}).
|
||||||
|
Where("id = ?", link.ExpenseNonstockID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"qty": payload.ReceivedQty,
|
||||||
|
"price": pricePerItem,
|
||||||
|
"notes": note,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if link.ExpenseID != 0 {
|
||||||
|
updatedExpenses[link.ExpenseID] = struct{}{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supplier/date changed: if the linked expense has only this nonstock, update it in place.
|
||||||
|
if link.ExpenseID != 0 {
|
||||||
|
var cnt int64
|
||||||
|
if err := b.db.WithContext(ctx).
|
||||||
|
Model(&entity.ExpenseNonstock{}).
|
||||||
|
Where("expense_id = ?", link.ExpenseID).
|
||||||
|
Count(&cnt).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cnt == 1 {
|
||||||
|
if item.Warehouse == nil || item.Warehouse.KandangId == nil || *item.Warehouse.KandangId == 0 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs")
|
||||||
|
}
|
||||||
|
newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID)
|
||||||
|
if err := b.db.WithContext(ctx).
|
||||||
|
Model(&entity.Expense{}).
|
||||||
|
Where("id = ?", link.ExpenseID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"transaction_date": newDate,
|
||||||
|
"supplier_id": supplierID,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updateBody := map[string]interface{}{
|
||||||
|
"qty": payload.ReceivedQty,
|
||||||
|
"price": pricePerItem,
|
||||||
|
"notes": note,
|
||||||
|
"nonstock_id": newNonstockID,
|
||||||
|
"kandang_id": uint64(*item.Warehouse.KandangId),
|
||||||
|
}
|
||||||
|
if err := b.db.WithContext(ctx).
|
||||||
|
Model(&entity.ExpenseNonstock{}).
|
||||||
|
Where("id = ?", link.ExpenseNonstockID).
|
||||||
|
Updates(updateBody).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updatedExpenses[link.ExpenseID] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expense has multiple nonstocks: create new expense header for this item, then move existing nonstock to it.
|
||||||
|
var kandangID *uint
|
||||||
|
var projectFK *uint
|
||||||
|
if item.Warehouse != nil && item.Warehouse.KandangId != nil {
|
||||||
|
id := uint(*item.Warehouse.KandangId)
|
||||||
|
kandangID = &id
|
||||||
|
if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil {
|
||||||
|
pid := uint(project.Id)
|
||||||
|
projectFK = &pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pricePerItem := item.Price
|
||||||
|
if payload.TransportPerItem != nil {
|
||||||
|
pricePerItem = *payload.TransportPerItem
|
||||||
|
}
|
||||||
|
totalPrice := pricePerItem * payload.ReceivedQty
|
||||||
|
|
||||||
|
gItem := groupedItem{
|
||||||
|
item: item,
|
||||||
|
payload: payload,
|
||||||
|
projectFK: projectFK,
|
||||||
|
kandangID: kandangID,
|
||||||
|
totalPrice: totalPrice,
|
||||||
|
}
|
||||||
|
|
||||||
|
newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
expenseDetail, err := b.createExpenseViaService(c, purchase, []groupedItem{gItem}, newDate, newNonstockID, purchase.PoNumber, supplierID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdNonstockID uint64
|
||||||
|
if expenseDetail != nil {
|
||||||
|
noteMap := mapExpenseNotes(expenseDetail)
|
||||||
|
createdNonstockID = noteMap[payload.PurchaseItemID]
|
||||||
|
}
|
||||||
|
|
||||||
|
note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID)
|
||||||
|
updateBody := map[string]interface{}{
|
||||||
|
"expense_id": expenseDetail.Id,
|
||||||
|
"qty": payload.ReceivedQty,
|
||||||
|
"price": pricePerItem,
|
||||||
|
"notes": note,
|
||||||
|
"nonstock_id": newNonstockID,
|
||||||
|
}
|
||||||
|
if kandangID != nil {
|
||||||
|
updateBody["kandang_id"] = uint64(*kandangID)
|
||||||
|
}
|
||||||
|
if projectFK != nil {
|
||||||
|
updateBody["project_flock_kandang_id"] = uint64(*projectFK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.db.WithContext(ctx).
|
||||||
|
Model(&entity.ExpenseNonstock{}).
|
||||||
|
Where("id = ?", link.ExpenseNonstockID).
|
||||||
|
Updates(updateBody).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if createdNonstockID != 0 {
|
||||||
|
if err := b.db.WithContext(ctx).Delete(&entity.ExpenseNonstock{}, createdNonstockID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if link.ExpenseID != 0 {
|
||||||
|
updatedExpenses[link.ExpenseID] = struct{}{}
|
||||||
|
}
|
||||||
|
if expenseDetail != nil && expenseDetail.Id != 0 {
|
||||||
|
updatedExpenses[uint64(expenseDetail.Id)] = struct{}{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise create new expense/nonstock in grouping flow.
|
||||||
|
}
|
||||||
|
|
||||||
|
baseKey := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID)
|
||||||
|
key := baseKey
|
||||||
|
if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 {
|
||||||
|
key = fmt.Sprintf("%s:%d", baseKey, payload.PurchaseItemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var kandangID *uint
|
||||||
|
var projectFK *uint
|
||||||
|
if item.Warehouse != nil && item.Warehouse.KandangId != nil {
|
||||||
|
id := uint(*item.Warehouse.KandangId)
|
||||||
|
kandangID = &id
|
||||||
|
if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil {
|
||||||
|
pid := uint(project.Id)
|
||||||
|
projectFK = &pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pricePerItem := item.Price
|
||||||
|
if payload.TransportPerItem != nil {
|
||||||
|
pricePerItem = *payload.TransportPerItem
|
||||||
|
}
|
||||||
|
totalPrice := pricePerItem * payload.ReceivedQty
|
||||||
|
|
||||||
|
groups[key] = append(groups[key], groupedItem{
|
||||||
|
item: item,
|
||||||
|
payload: payload,
|
||||||
|
projectFK: projectFK,
|
||||||
|
kandangID: kandangID,
|
||||||
|
totalPrice: totalPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, items := range groups {
|
||||||
|
if len(items) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(key, ":")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return errors.New("invalid expense grouping key")
|
||||||
|
}
|
||||||
|
expenseDate, err := utils.ParseDateString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
supplierID, err := strconv.ParseUint(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
expeditionNonstockID, err := b.findExpeditionNonstockID(ctx, uint(supplierID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
expenseDetail, err := b.createExpenseViaService(c, purchase, items, expenseDate, expeditionNonstockID, purchase.PoNumber, uint(supplierID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.linkExpenseNonstocksToItems(ctx, expenseDetail, items); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if expenseDetail != nil && expenseDetail.Id != 0 {
|
||||||
|
updatedExpenses[uint64(expenseDetail.Id)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updatedExpenses) > 0 {
|
||||||
|
if err := b.markExpensesUpdated(ctx, updatedExpenses, purchase.CreatedBy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *noopPurchaseExpenseBridge) OnItemsReceived(_ context.Context, _ uint, _ []ExpenseReceivingPayload) error {
|
func (b *expenseBridge) findExpeditionNonstockID(ctx context.Context, supplierID uint) (uint64, error) {
|
||||||
|
var id uint64
|
||||||
|
err := b.db.WithContext(ctx).
|
||||||
|
Table("nonstocks AS ns").
|
||||||
|
Select("ns.id").
|
||||||
|
Joins("JOIN nonstock_suppliers nss ON nss.nonstock_id = ns.id").
|
||||||
|
Joins("JOIN flags f ON f.flagable_id = ns.id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
|
||||||
|
Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))).
|
||||||
|
Where("nss.supplier_id = ?", supplierID).
|
||||||
|
Order("ns.id").
|
||||||
|
Limit(1).
|
||||||
|
Scan(&id).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
return 0, fiber.NewError(fiber.StatusBadRequest, "supplier id tidak sesuai dengan expedisi")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractIDs(items []entity.PurchaseItem) []uint {
|
||||||
|
result := make([]uint, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Id != 0 {
|
||||||
|
result = append(result, item.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *expenseBridge) createExpenseViaService(
|
||||||
|
c *fiber.Ctx,
|
||||||
|
purchase *entity.Purchase,
|
||||||
|
items []groupedItem,
|
||||||
|
expenseDate time.Time,
|
||||||
|
expeditionNonstockID uint64,
|
||||||
|
poNumber *string,
|
||||||
|
supplierID uint,
|
||||||
|
) (*expenseDto.ExpenseDetailDTO, error) {
|
||||||
|
ctx := c.Context()
|
||||||
|
if b.expenseSvc == nil {
|
||||||
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "expense service not available")
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "no items to create expense")
|
||||||
|
}
|
||||||
|
|
||||||
|
kandangID := items[0].kandangID
|
||||||
|
if kandangID == nil || *kandangID == 0 {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs")
|
||||||
|
}
|
||||||
|
|
||||||
|
costItems := make([]expenseValidation.CostItem, 0, len(items))
|
||||||
|
for _, gi := range items {
|
||||||
|
note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID)
|
||||||
|
price := gi.item.Price
|
||||||
|
if gi.payload.TransportPerItem != nil {
|
||||||
|
price = *gi.payload.TransportPerItem
|
||||||
|
}
|
||||||
|
costItems = append(costItems, expenseValidation.CostItem{
|
||||||
|
NonstockID: expeditionNonstockID,
|
||||||
|
Quantity: gi.payload.ReceivedQty,
|
||||||
|
Price: price,
|
||||||
|
Notes: note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &expenseValidation.Create{
|
||||||
|
PoNumber: "",
|
||||||
|
TransactionDate: utils.FormatDate(expenseDate),
|
||||||
|
Category: "BOP",
|
||||||
|
SupplierID: uint64(supplierID),
|
||||||
|
ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{
|
||||||
|
KandangID: uint64(*kandangID),
|
||||||
|
CostItems: costItems,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if poNumber != nil {
|
||||||
|
req.PoNumber = *poNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := b.expenseSvc.CreateOne(c, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark approvals up to Finance so latest is Manager Finance
|
||||||
|
action := entity.ApprovalActionApproved
|
||||||
|
actorID := uint(purchase.CreatedBy)
|
||||||
|
if actorID == 0 {
|
||||||
|
actorID = 1
|
||||||
|
}
|
||||||
|
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db))
|
||||||
|
if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedItem) error {
|
||||||
|
if detail == nil || len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
noteToExpenseNonstock := mapExpenseNotes(detail)
|
||||||
|
|
||||||
|
if len(noteToExpenseNonstock) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, gi := range items {
|
||||||
|
expenseNonstockID, ok := noteToExpenseNonstock[gi.payload.PurchaseItemID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := b.db.WithContext(ctx).
|
||||||
|
Model(&entity.PurchaseItem{}).
|
||||||
|
Where("id = ?", gi.payload.PurchaseItemID).
|
||||||
|
Update("expense_nonstock_id", expenseNonstockID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapExpenseNotes(detail *expenseDto.ExpenseDetailDTO) map[uint]uint64 {
|
||||||
|
result := make(map[uint]uint64)
|
||||||
|
if detail == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, kandang := range detail.Kandangs {
|
||||||
|
for _, pengajuan := range kandang.Pengajuans {
|
||||||
|
note := strings.TrimSpace(pengajuan.Notes)
|
||||||
|
if note == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const prefix = "purchase_item:"
|
||||||
|
if !strings.HasPrefix(note, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idStr := strings.TrimPrefix(note, prefix)
|
||||||
|
var itemID uint
|
||||||
|
if _, err := fmt.Sscanf(idStr, "%d", &itemID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[itemID] = pengajuan.Id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import (
|
|||||||
rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/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"
|
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
|
||||||
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/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"
|
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
|
||||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
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/go-playground/validator/v10"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -39,26 +41,28 @@ type PurchaseService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
priceTolerance = 0.0001
|
priceTolerance = 0.0001
|
||||||
|
purchaseStockableKey = fifo.StockableKey("PURCHASE_ITEMS")
|
||||||
)
|
)
|
||||||
|
|
||||||
type purchaseService struct {
|
type purchaseService struct {
|
||||||
Log *logrus.Logger
|
Log *logrus.Logger
|
||||||
Validate *validator.Validate
|
Validate *validator.Validate
|
||||||
PurchaseRepo rPurchase.PurchaseRepository
|
PurchaseRepo rPurchase.PurchaseRepository
|
||||||
ProductRepo rProduct.ProductRepository
|
ProductRepo rProduct.ProductRepository
|
||||||
WarehouseRepo rWarehouse.WarehouseRepository
|
WarehouseRepo rWarehouse.WarehouseRepository
|
||||||
SupplierRepo rSupplier.SupplierRepository
|
SupplierRepo rSupplier.SupplierRepository
|
||||||
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
|
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
|
||||||
ApprovalSvc commonSvc.ApprovalService
|
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
|
||||||
ExpenseBridge PurchaseExpenseBridge
|
ApprovalSvc commonSvc.ApprovalService
|
||||||
approvalWorkflow approvalutils.ApprovalWorkflowKey
|
ExpenseBridge PurchaseExpenseBridge
|
||||||
|
FifoSvc commonSvc.FifoService
|
||||||
|
approvalWorkflow approvalutils.ApprovalWorkflowKey
|
||||||
}
|
}
|
||||||
|
|
||||||
type staffAdjustmentPayload struct {
|
type staffAdjustmentPayload struct {
|
||||||
PricingUpdates []rPurchase.PurchasePricingUpdate
|
PricingUpdates []rPurchase.PurchasePricingUpdate
|
||||||
NewItems []*entity.PurchaseItem
|
NewItems []*entity.PurchaseItem
|
||||||
GrandTotal float64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPurchaseService(
|
func NewPurchaseService(
|
||||||
@@ -68,23 +72,24 @@ func NewPurchaseService(
|
|||||||
warehouseRepo rWarehouse.WarehouseRepository,
|
warehouseRepo rWarehouse.WarehouseRepository,
|
||||||
supplierRepo rSupplier.SupplierRepository,
|
supplierRepo rSupplier.SupplierRepository,
|
||||||
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
|
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
|
||||||
|
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
|
||||||
approvalSvc commonSvc.ApprovalService,
|
approvalSvc commonSvc.ApprovalService,
|
||||||
expenseBridge PurchaseExpenseBridge,
|
expenseBridge PurchaseExpenseBridge,
|
||||||
|
fifoSvc commonSvc.FifoService,
|
||||||
) PurchaseService {
|
) PurchaseService {
|
||||||
if expenseBridge == nil {
|
|
||||||
expenseBridge = NewNoopPurchaseExpenseBridge()
|
|
||||||
}
|
|
||||||
return &purchaseService{
|
return &purchaseService{
|
||||||
Log: utils.Log,
|
Log: utils.Log,
|
||||||
Validate: validate,
|
Validate: validate,
|
||||||
PurchaseRepo: purchaseRepo,
|
PurchaseRepo: purchaseRepo,
|
||||||
ProductRepo: productRepo,
|
ProductRepo: productRepo,
|
||||||
WarehouseRepo: warehouseRepo,
|
WarehouseRepo: warehouseRepo,
|
||||||
SupplierRepo: supplierRepo,
|
SupplierRepo: supplierRepo,
|
||||||
ProductWarehouseRepo: productWarehouseRepo,
|
ProductWarehouseRepo: productWarehouseRepo,
|
||||||
ApprovalSvc: approvalSvc,
|
ProjectFlockKandangRepo: projectFlockKandangRepo,
|
||||||
ExpenseBridge: expenseBridge,
|
ApprovalSvc: approvalSvc,
|
||||||
approvalWorkflow: utils.ApprovalWorkflowPurchase,
|
ExpenseBridge: expenseBridge,
|
||||||
|
FifoSvc: fifoSvc,
|
||||||
|
approvalWorkflow: utils.ApprovalWorkflowPurchase,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB {
|
func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB {
|
||||||
@@ -114,9 +119,9 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
|||||||
|
|
||||||
offset := (params.Page - 1) * params.Limit
|
offset := (params.Page - 1) * params.Limit
|
||||||
|
|
||||||
createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo)
|
createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
||||||
@@ -225,6 +230,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
|
|||||||
productId uint
|
productId uint
|
||||||
warehouseId uint
|
warehouseId uint
|
||||||
subQty float64
|
subQty float64
|
||||||
|
pfkID *uint
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(req.Items) == 0 {
|
if len(req.Items) == 0 {
|
||||||
@@ -233,31 +239,47 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
|
|||||||
|
|
||||||
warehouseCache := make(map[uint]*entity.Warehouse)
|
warehouseCache := make(map[uint]*entity.Warehouse)
|
||||||
productSupplierCache := make(map[uint]bool)
|
productSupplierCache := make(map[uint]bool)
|
||||||
getWarehouse := func(id uint) (*entity.Warehouse, error) {
|
getWarehouse := func(id uint) (*entity.Warehouse, *uint, error) {
|
||||||
if warehouse, ok := warehouseCache[id]; ok {
|
if warehouse, ok := warehouseCache[id]; ok {
|
||||||
return warehouse, nil
|
return warehouse, nil, nil
|
||||||
}
|
}
|
||||||
warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
|
warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
|
||||||
return db.Preload("Area").Preload("location")
|
return db.Preload("Area").Preload("Location")
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id))
|
return nil, nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id))
|
||||||
}
|
}
|
||||||
s.Log.Errorf("Failed to get warehouse %d: %+v", id, err)
|
s.Log.Errorf("Failed to get warehouse %d: %+v", id, err)
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse")
|
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse")
|
||||||
|
}
|
||||||
|
if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
|
||||||
|
return nil, nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse %d is not linked to a kandang", id))
|
||||||
|
}
|
||||||
|
var pfkID *uint
|
||||||
|
if s.ProjectFlockKandangRepo != nil {
|
||||||
|
if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil {
|
||||||
|
idCopy := uint(pfk.Id)
|
||||||
|
pfkID = &idCopy
|
||||||
|
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse %d has no active project flock", id))
|
||||||
|
} else if err != nil {
|
||||||
|
s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err)
|
||||||
|
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
warehouseCache[id] = warehouse
|
warehouseCache[id] = warehouse
|
||||||
return warehouse, nil
|
return warehouse, pfkID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
aggregated := make([]*aggregatedItem, 0, len(req.Items))
|
aggregated := make([]*aggregatedItem, 0, len(req.Items))
|
||||||
indexMap := make(map[string]int)
|
indexMap := make(map[string]int)
|
||||||
|
|
||||||
for _, item := range req.Items {
|
for _, item := range req.Items {
|
||||||
if _, err := getWarehouse(item.WarehouseID); err != nil {
|
_, pfkID, err := getWarehouse(item.WarehouseID)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,35 +308,42 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
|
|||||||
productId: productId,
|
productId: productId,
|
||||||
warehouseId: warehouseId,
|
warehouseId: warehouseId,
|
||||||
subQty: item.Quantity,
|
subQty: item.Quantity,
|
||||||
|
pfkID: pfkID,
|
||||||
}
|
}
|
||||||
aggregated = append(aggregated, entry)
|
aggregated = append(aggregated, entry)
|
||||||
indexMap[key] = len(aggregated) - 1
|
indexMap[key] = len(aggregated) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
creditTermValue := req.CreditTerm
|
var dueDate *time.Time
|
||||||
creditTerm := &creditTermValue
|
if req.DueDate != nil && strings.TrimSpace(*req.DueDate) != "" {
|
||||||
dueDateValue := time.Now().UTC().AddDate(0, 0, creditTermValue)
|
parsed, err := utils.ParseDateString(strings.TrimSpace(*req.DueDate))
|
||||||
dueDate := &dueDateValue
|
if err != nil {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid due_date, expected YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
parsed = parsed.UTC()
|
||||||
|
dueDate = &parsed
|
||||||
|
}
|
||||||
|
|
||||||
purchase := &entity.Purchase{
|
purchase := &entity.Purchase{
|
||||||
SupplierId: uint(req.SupplierID),
|
SupplierId: uint(req.SupplierID),
|
||||||
CreditTerm: creditTerm,
|
|
||||||
DueDate: dueDate,
|
DueDate: dueDate,
|
||||||
GrandTotal: 0,
|
|
||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
CreatedBy: uint(actorID),
|
CreatedBy: uint(actorID),
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]*entity.PurchaseItem, 0, len(aggregated))
|
items := make([]*entity.PurchaseItem, 0, len(aggregated))
|
||||||
|
emptyVehicle := ""
|
||||||
for _, item := range aggregated {
|
for _, item := range aggregated {
|
||||||
items = append(items, &entity.PurchaseItem{
|
items = append(items, &entity.PurchaseItem{
|
||||||
ProductId: item.productId,
|
ProductId: item.productId,
|
||||||
WarehouseId: item.warehouseId,
|
WarehouseId: item.warehouseId,
|
||||||
SubQty: item.subQty,
|
ProjectFlockKandangId: item.pfkID,
|
||||||
TotalQty: 0,
|
SubQty: item.subQty,
|
||||||
TotalUsed: 0,
|
TotalQty: 0,
|
||||||
Price: 0,
|
TotalUsed: 0,
|
||||||
TotalPrice: 0,
|
Price: 0,
|
||||||
|
TotalPrice: 0,
|
||||||
|
VehicleNumber: &emptyVehicle,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +360,10 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := purchaseRepoTx.BackfillProjectFlockKandang(c.Context(), purchase.Id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
actorID := uint(purchase.CreatedBy)
|
actorID := uint(purchase.CreatedBy)
|
||||||
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil {
|
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -361,6 +394,8 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := c.Context()
|
||||||
|
|
||||||
action, err := parseApprovalActionInput(req.Action)
|
action, err := parseApprovalActionInput(req.Action)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -371,7 +406,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations)
|
purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found")
|
return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found")
|
||||||
@@ -379,7 +414,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
|
|||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.attachLatestApproval(c.Context(), purchase); err != nil {
|
if err := s.attachLatestApproval(ctx, purchase); err != nil {
|
||||||
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
|
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,12 +453,10 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
|
|||||||
|
|
||||||
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
|
purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
|
||||||
grandTotalUpdated := false
|
|
||||||
if len(payload.PricingUpdates) > 0 {
|
if len(payload.PricingUpdates) > 0 {
|
||||||
if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil {
|
if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
grandTotalUpdated = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(payload.NewItems) > 0 {
|
if len(payload.NewItems) > 0 {
|
||||||
@@ -432,12 +465,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !grandTotalUpdated {
|
|
||||||
if err := purchaseRepoTx.UpdateGrandTotal(c.Context(), purchase.Id, payload.GrandTotal); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isInitialApproval {
|
if isInitialApproval {
|
||||||
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil {
|
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -481,17 +508,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
|
|||||||
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(payload.NewItems) > 0 {
|
|
||||||
newItems := make([]entity.PurchaseItem, len(payload.NewItems))
|
|
||||||
for i, item := range payload.NewItems {
|
|
||||||
if item == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newItems[i] = *item
|
|
||||||
}
|
|
||||||
s.notifyExpenseItemsCreated(c.Context(), purchase.Id, newItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +627,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := c.Context()
|
||||||
|
|
||||||
action, err := parseApprovalActionInput(req.Action)
|
action, err := parseApprovalActionInput(req.Action)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -621,7 +639,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations)
|
purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found")
|
return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found")
|
||||||
@@ -647,14 +665,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
}
|
}
|
||||||
|
|
||||||
if action == entity.ApprovalActionRejected {
|
if action == entity.ApprovalActionRejected {
|
||||||
if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil {
|
if err := s.createPurchaseApproval(ctx, nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
|
updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase")
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase")
|
||||||
}
|
}
|
||||||
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
|
if err := s.attachLatestApproval(ctx, updated); err != nil {
|
||||||
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
|
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
|
||||||
}
|
}
|
||||||
return updated, nil
|
return updated, nil
|
||||||
@@ -670,6 +688,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
payload validation.ReceivePurchaseItemRequest
|
payload validation.ReceivePurchaseItemRequest
|
||||||
receivedDate time.Time
|
receivedDate time.Time
|
||||||
warehouseID uint
|
warehouseID uint
|
||||||
|
supplierID uint
|
||||||
|
transportPerItem *float64
|
||||||
overrideWarehouse bool
|
overrideWarehouse bool
|
||||||
receivedQty float64
|
receivedQty float64
|
||||||
}
|
}
|
||||||
@@ -682,7 +702,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID))
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID))
|
||||||
}
|
}
|
||||||
|
|
||||||
receivedDate, err := time.Parse("2006-01-02", payload.ReceivedDate)
|
receivedDate, err := utils.ParseDateString(payload.ReceivedDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID))
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID))
|
||||||
}
|
}
|
||||||
@@ -697,6 +717,9 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
if warehouseID == 0 {
|
if warehouseID == 0 {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID))
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID))
|
||||||
}
|
}
|
||||||
|
if payload.WarehouseID != nil && uint(item.WarehouseId) != warehouseID {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving does not allow changing warehouse")
|
||||||
|
}
|
||||||
|
|
||||||
var receivedQty float64
|
var receivedQty float64
|
||||||
if payload.ReceivedQty != nil {
|
if payload.ReceivedQty != nil {
|
||||||
@@ -716,11 +739,27 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
}
|
}
|
||||||
visitedItems[payload.PurchaseItemID] = struct{}{}
|
visitedItems[payload.PurchaseItemID] = struct{}{}
|
||||||
|
|
||||||
|
supplierID := purchase.SupplierId
|
||||||
|
if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 {
|
||||||
|
supplierID = *payload.ExpeditionVendorID
|
||||||
|
}
|
||||||
|
|
||||||
|
var transportPerItem *float64
|
||||||
|
if payload.TransportPerItem != nil {
|
||||||
|
if *payload.TransportPerItem < 0 {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID))
|
||||||
|
}
|
||||||
|
val := *payload.TransportPerItem
|
||||||
|
transportPerItem = &val
|
||||||
|
}
|
||||||
|
|
||||||
prepared = append(prepared, preparedReceiving{
|
prepared = append(prepared, preparedReceiving{
|
||||||
item: item,
|
item: item,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
receivedDate: receivedDate,
|
receivedDate: receivedDate,
|
||||||
warehouseID: warehouseID,
|
warehouseID: warehouseID,
|
||||||
|
supplierID: supplierID,
|
||||||
|
transportPerItem: transportPerItem,
|
||||||
overrideWarehouse: overrideWarehouse,
|
overrideWarehouse: overrideWarehouse,
|
||||||
receivedQty: receivedQty,
|
receivedQty: receivedQty,
|
||||||
})
|
})
|
||||||
@@ -737,7 +776,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
approvalSvc := commonSvc.NewApprovalService(
|
approvalSvc := commonSvc.NewApprovalService(
|
||||||
commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()),
|
commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()),
|
||||||
)
|
)
|
||||||
|
|
||||||
if approvalSvc != nil {
|
if approvalSvc != nil {
|
||||||
filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB {
|
filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB {
|
||||||
return func(db *gorm.DB) *gorm.DB {
|
return func(db *gorm.DB) *gorm.DB {
|
||||||
@@ -767,6 +806,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
deltas := make(map[uint]float64)
|
deltas := make(map[uint]float64)
|
||||||
affected := make(map[uint]struct{})
|
affected := make(map[uint]struct{})
|
||||||
updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared))
|
updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared))
|
||||||
|
fifoAdds := make([]struct {
|
||||||
|
itemID uint
|
||||||
|
pwID uint
|
||||||
|
qty float64
|
||||||
|
}, 0, len(prepared))
|
||||||
|
|
||||||
for _, prep := range prepared {
|
for _, prep := range prepared {
|
||||||
item := prep.item
|
item := prep.item
|
||||||
@@ -780,21 +824,29 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
var newPWID *uint
|
var newPWID *uint
|
||||||
clearPW := false
|
clearPW := false
|
||||||
|
|
||||||
|
// Always ensure PW when qty > 0 so stockable has target.
|
||||||
if prep.receivedQty > 0 {
|
if prep.receivedQty > 0 {
|
||||||
pwID, err := pwRepoTx.EnsureProductWarehouse(c.Context(), uint(item.ProductId), prep.warehouseID, purchase.CreatedBy)
|
pwID, err := pwRepoTx.EnsureProductWarehouse(c.Context(), uint(item.ProductId), prep.warehouseID, purchase.CreatedBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
newPWID = &pwID
|
newPWID = &pwID
|
||||||
deltas[pwID] += prep.receivedQty
|
} else if oldPWID != nil {
|
||||||
affected[pwID] = struct{}{}
|
newPWID = oldPWID
|
||||||
} else {
|
|
||||||
clearPW = true
|
clearPW = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldPWID != nil {
|
deltaQty := prep.receivedQty - item.TotalQty
|
||||||
deltas[*oldPWID] -= item.TotalQty
|
switch {
|
||||||
affected[*oldPWID] = struct{}{}
|
case deltaQty > 0 && newPWID != nil:
|
||||||
|
fifoAdds = append(fifoAdds, struct {
|
||||||
|
itemID uint
|
||||||
|
pwID uint
|
||||||
|
qty float64
|
||||||
|
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
|
||||||
|
case deltaQty < 0 && newPWID != nil:
|
||||||
|
deltas[*newPWID] += deltaQty // negative
|
||||||
|
affected[*newPWID] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
dateCopy := prep.receivedDate
|
dateCopy := prep.receivedDate
|
||||||
@@ -830,12 +882,21 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil {
|
if s.FifoSvc != nil {
|
||||||
return err
|
for _, adj := range fifoAdds {
|
||||||
}
|
if adj.pwID == 0 || adj.qty <= 0 {
|
||||||
|
continue
|
||||||
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil {
|
}
|
||||||
return err
|
if _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
|
||||||
|
StockableKey: purchaseStockableKey,
|
||||||
|
StockableID: adj.itemID,
|
||||||
|
ProductWarehouseID: adj.pwID,
|
||||||
|
Quantity: adj.qty,
|
||||||
|
Tx: tx,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -860,15 +921,31 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
for _, prep := range prepared {
|
for _, prep := range prepared {
|
||||||
date := prep.receivedDate
|
date := prep.receivedDate
|
||||||
payload := ExpenseReceivingPayload{
|
payload := ExpenseReceivingPayload{
|
||||||
PurchaseItemID: prep.item.Id,
|
PurchaseItemID: prep.item.Id,
|
||||||
ProductID: prep.item.ProductId,
|
ProductID: prep.item.ProductId,
|
||||||
WarehouseID: uint(prep.warehouseID),
|
WarehouseID: uint(prep.warehouseID),
|
||||||
ReceivedQty: prep.receivedQty,
|
SupplierID: prep.supplierID,
|
||||||
ReceivedDate: &date,
|
TransportPerItem: prep.transportPerItem,
|
||||||
|
ReceivedQty: prep.receivedQty,
|
||||||
|
ReceivedDate: &date,
|
||||||
}
|
}
|
||||||
receivingPayloads = append(receivingPayloads, payload)
|
receivingPayloads = append(receivingPayloads, payload)
|
||||||
}
|
}
|
||||||
s.notifyExpenseItemsReceived(c.Context(), purchase.Id, receivingPayloads)
|
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, fiber.NewError(fiber.StatusInternalServerError, "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
|
return updated, nil
|
||||||
}
|
}
|
||||||
@@ -918,6 +995,17 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
|
|||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Requested items were not found in this purchase")
|
return nil, fiber.NewError(fiber.StatusBadRequest, "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 {
|
if len(purchase.Items)-len(toDelete) <= 0 {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item")
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item")
|
||||||
}
|
}
|
||||||
@@ -929,10 +1017,6 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if transactionErr != nil {
|
if transactionErr != nil {
|
||||||
@@ -942,8 +1026,14 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
|
|||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items")
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(toDelete) > 0 {
|
if len(itemsToDelete) > 0 {
|
||||||
s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete)
|
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, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations)
|
updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations)
|
||||||
@@ -971,9 +1061,9 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
|
|||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
|
||||||
}
|
}
|
||||||
|
|
||||||
itemIDs := make([]uint, 0, len(purchase.Items))
|
itemsToDelete := make([]entity.PurchaseItem, len(purchase.Items))
|
||||||
for _, item := range purchase.Items {
|
for i, item := range purchase.Items {
|
||||||
itemIDs = append(itemIDs, item.Id)
|
itemsToDelete[i] = item
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -995,38 +1085,87 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
|
|||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase")
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(itemIDs) > 0 {
|
if len(itemsToDelete) > 0 {
|
||||||
s.notifyExpenseItemsDeleted(ctx, uint(id), itemIDs)
|
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 fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) {
|
func (s *purchaseService) createPurchaseApproval(
|
||||||
if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 {
|
ctx context.Context,
|
||||||
return
|
db *gorm.DB,
|
||||||
|
purchaseID uint,
|
||||||
|
step approvalutils.ApprovalStep,
|
||||||
|
action entity.ApprovalAction,
|
||||||
|
actorID uint,
|
||||||
|
notes *string,
|
||||||
|
allowDuplicate bool,
|
||||||
|
) error {
|
||||||
|
if purchaseID == 0 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval")
|
||||||
}
|
}
|
||||||
if err := s.ExpenseBridge.OnItemsCreated(ctx, purchaseID, items); err != nil {
|
if actorID == 0 {
|
||||||
s.Log.Warnf("Failed to notify expense bridge for created purchase %d: %+v", purchaseID, err)
|
actorID = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svc := s.approvalServiceForDB(db)
|
||||||
|
if svc == nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "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) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint, payloads []ExpenseReceivingPayload) {
|
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 {
|
if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 {
|
||||||
return
|
return nil
|
||||||
}
|
|
||||||
if err := s.ExpenseBridge.OnItemsReceived(ctx, purchaseID, payloads); err != nil {
|
|
||||||
s.Log.Warnf("Failed to notify expense bridge for received purchase %d: %+v", purchaseID, err)
|
|
||||||
}
|
}
|
||||||
|
return s.ExpenseBridge.OnItemsReceived(c, purchaseID, payloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) {
|
func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error {
|
||||||
if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 {
|
if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 {
|
||||||
return
|
return nil
|
||||||
}
|
|
||||||
if err := s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, itemIDs); err != nil {
|
|
||||||
s.Log.Warnf("Failed to notify expense bridge for deleted purchase %d: %+v", purchaseID, err)
|
|
||||||
}
|
}
|
||||||
|
return s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, items)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *purchaseService) buildStaffAdjustmentPayload(
|
func (s *purchaseService) buildStaffAdjustmentPayload(
|
||||||
@@ -1054,7 +1193,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items))
|
updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items))
|
||||||
var grandTotal float64
|
|
||||||
|
|
||||||
existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads))
|
existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads))
|
||||||
for _, item := range purchase.Items {
|
for _, item := range purchase.Items {
|
||||||
@@ -1120,7 +1258,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
updates = append(updates, update)
|
updates = append(updates, update)
|
||||||
grandTotal += totalPrice
|
|
||||||
delete(requestItems, item.Id)
|
delete(requestItems, item.Id)
|
||||||
}
|
}
|
||||||
if len(requestItems) > 0 {
|
if len(requestItems) > 0 {
|
||||||
@@ -1129,6 +1266,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
|
|||||||
|
|
||||||
productSupplierCache := make(map[uint]bool)
|
productSupplierCache := make(map[uint]bool)
|
||||||
newItems := make([]*entity.PurchaseItem, 0, len(newPayloads))
|
newItems := make([]*entity.PurchaseItem, 0, len(newPayloads))
|
||||||
|
emptyVehicle := ""
|
||||||
|
|
||||||
for _, payload := range newPayloads {
|
for _, payload := range newPayloads {
|
||||||
if payload.ProductID == 0 || payload.WarehouseID == 0 {
|
if payload.ProductID == 0 || payload.WarehouseID == 0 {
|
||||||
@@ -1175,18 +1313,18 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
newItem := &entity.PurchaseItem{
|
newItem := &entity.PurchaseItem{
|
||||||
PurchaseId: purchase.Id,
|
PurchaseId: purchase.Id,
|
||||||
ProductId: payload.ProductID,
|
ProductId: payload.ProductID,
|
||||||
WarehouseId: payload.WarehouseID,
|
WarehouseId: payload.WarehouseID,
|
||||||
SubQty: qty,
|
SubQty: qty,
|
||||||
TotalQty: 0,
|
TotalQty: 0,
|
||||||
TotalUsed: 0,
|
TotalUsed: 0,
|
||||||
Price: payload.Price,
|
Price: payload.Price,
|
||||||
TotalPrice: totalPrice,
|
TotalPrice: totalPrice,
|
||||||
|
VehicleNumber: &emptyVehicle,
|
||||||
}
|
}
|
||||||
newItems = append(newItems, newItem)
|
newItems = append(newItems, newItem)
|
||||||
existingCombos[key] = struct{}{}
|
existingCombos[key] = struct{}{}
|
||||||
grandTotal += totalPrice
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(updates) == 0 && len(newItems) == 0 {
|
if len(updates) == 0 && len(newItems) == 0 {
|
||||||
@@ -1196,7 +1334,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
|
|||||||
return &staffAdjustmentPayload{
|
return &staffAdjustmentPayload{
|
||||||
PricingUpdates: updates,
|
PricingUpdates: updates,
|
||||||
NewItems: newItems,
|
NewItems: newItems,
|
||||||
GrandTotal: grandTotal,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1239,36 +1376,6 @@ func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) {
|
|
||||||
var fromPtr *time.Time
|
|
||||||
var toPtr *time.Time
|
|
||||||
const queryDateLayout = "2006-01-02"
|
|
||||||
|
|
||||||
if strings.TrimSpace(fromStr) != "" {
|
|
||||||
parsed, err := time.Parse(queryDateLayout, fromStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must use format YYYY-MM-DD")
|
|
||||||
}
|
|
||||||
fromValue := parsed
|
|
||||||
fromPtr = &fromValue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(toStr) != "" {
|
|
||||||
parsed, err := time.Parse(queryDateLayout, toStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_to must use format YYYY-MM-DD")
|
|
||||||
}
|
|
||||||
toValue := parsed.AddDate(0, 0, 1)
|
|
||||||
toPtr = &toValue
|
|
||||||
}
|
|
||||||
|
|
||||||
if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) {
|
|
||||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must be earlier than created_to")
|
|
||||||
}
|
|
||||||
|
|
||||||
return fromPtr, toPtr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) {
|
func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) {
|
||||||
value := strings.ToUpper(strings.TrimSpace(raw))
|
value := strings.ToUpper(strings.TrimSpace(raw))
|
||||||
switch value {
|
switch value {
|
||||||
@@ -1302,53 +1409,3 @@ func (s *purchaseService) rejectAndReload(
|
|||||||
}
|
}
|
||||||
return updated, nil
|
return updated, 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 fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval")
|
|
||||||
}
|
|
||||||
if actorID == 0 {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "ActorId is invalid for approval")
|
|
||||||
}
|
|
||||||
|
|
||||||
var svc commonSvc.ApprovalService
|
|
||||||
switch {
|
|
||||||
case db != nil:
|
|
||||||
svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db))
|
|
||||||
case s.ApprovalSvc != nil:
|
|
||||||
svc = s.ApprovalSvc
|
|
||||||
case s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil:
|
|
||||||
svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()))
|
|
||||||
}
|
|
||||||
if svc == nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type PurchaseItemPayload struct {
|
|||||||
|
|
||||||
type CreatePurchaseRequest struct {
|
type CreatePurchaseRequest struct {
|
||||||
SupplierID uint `json:"supplier_id" validate:"required,gt=0"`
|
SupplierID uint `json:"supplier_id" validate:"required,gt=0"`
|
||||||
CreditTerm int `json:"credit_term" validate:"required,gte=0"`
|
DueDate *string `json:"due_date,omitempty" validate:"omitempty,datetime=2006-01-02"`
|
||||||
Notes *string `json:"notes" validate:"omitempty,max=500"`
|
Notes *string `json:"notes" validate:"omitempty,max=500"`
|
||||||
Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"`
|
Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"`
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,8 @@ type ReceivePurchaseItemRequest struct {
|
|||||||
PurchaseItemID uint `json:"purchase_item_id" validate:"required,gt=0"`
|
PurchaseItemID uint `json:"purchase_item_id" validate:"required,gt=0"`
|
||||||
WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"`
|
WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"`
|
||||||
ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"`
|
ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"`
|
||||||
|
ExpeditionVendorID *uint `json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"`
|
||||||
|
TransportPerItem *float64 `json:"transport_per_item,omitempty" validate:"omitempty,gte=0"`
|
||||||
TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"`
|
TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"`
|
||||||
TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"`
|
TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"`
|
||||||
VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"`
|
VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"`
|
||||||
|
|||||||
+34
-1
@@ -1,8 +1,9 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseDateString mengubah string "YYYY-MM-DD" menjadi time.Time
|
// ParseDateString mengubah string "YYYY-MM-DD" menjadi time.Time
|
||||||
@@ -23,3 +24,35 @@ func ParseDateString(dateStr string) (time.Time, error) {
|
|||||||
func FormatDate(t time.Time) string {
|
func FormatDate(t time.Time) string {
|
||||||
return t.Format("2006-01-02")
|
return t.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseDateRangeForQuery parses optional YYYY-MM-DD from/to strings for list filters.
|
||||||
|
// It returns a start pointer (inclusive) and an end pointer advanced by one day
|
||||||
|
// so callers can safely use "< end" to achieve an inclusive upper bound.
|
||||||
|
func ParseDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) {
|
||||||
|
var fromPtr *time.Time
|
||||||
|
var toPtr *time.Time
|
||||||
|
|
||||||
|
if strings.TrimSpace(fromStr) != "" {
|
||||||
|
parsed, err := ParseDateString(strings.TrimSpace(fromStr))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.New("created_from must use format YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
fromValue := parsed
|
||||||
|
fromPtr = &fromValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(toStr) != "" {
|
||||||
|
parsed, err := ParseDateString(strings.TrimSpace(toStr))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.New("created_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("created_from must be earlier than created_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fromPtr, toPtr, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user