From 11f2389ec54ee41db4b0edda9b6b1eb1a749792a Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 17 Nov 2025 09:39:30 +0700 Subject: [PATCH 1/3] feat(BE-229,234,235,230,231,232,233): purchase request and purchase order and fix master data dto --- .../repository/common.approval.repository..go | 11 + .../20251104084540_purchase-items.up.sql | 19 +- .../20251104084555_purchases.up.sql | 24 +- internal/entities/purchase.go | 7 +- internal/entities/purchase_item.go | 13 +- .../product_warehouse.repository.go | 70 + .../master/customers/dto/customer.dto.go | 2 +- .../master/kandangs/dto/kandang.dto.go | 40 +- .../master/locations/dto/location.dto.go | 24 +- .../master/nonstocks/dto/nonstock.dto.go | 41 +- .../master/products/dto/product.dto.go | 38 +- .../repositories/product.repository.go | 12 + .../master/warehouses/dto/warehouse.dto.go | 50 +- .../controllers/purchase.controller.go | 176 ++- .../modules/purchases/dto/purchase.dto.go | 228 +-- internal/modules/purchases/module.go | 39 +- .../repositories/purchase.repository.go | 330 +++- internal/modules/purchases/route.go | 55 +- .../purchases/services/expense_bridge.go | 43 + .../purchases/services/purchase.service.go | 1332 +++++++++++++++-- .../validations/purchase.validation.go | 57 +- internal/utils/constant.go | 10 + 22 files changed, 2184 insertions(+), 437 deletions(-) create mode 100644 internal/modules/purchases/services/expense_bridge.go diff --git a/internal/common/repository/common.approval.repository..go b/internal/common/repository/common.approval.repository..go index 7f1c27ae..dc517f21 100644 --- a/internal/common/repository/common.approval.repository..go +++ b/internal/common/repository/common.approval.repository..go @@ -13,6 +13,7 @@ type ApprovalRepository interface { FindByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) LatestByTarget(ctx context.Context, workflow string, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) LatestByTargets(ctx context.Context, workflow string, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]entity.Approval, error) + DeleteByTarget(ctx context.Context, workflow string, approvableID uint) error } type approvalRepositoryImpl struct { @@ -104,3 +105,13 @@ func (r *approvalRepositoryImpl) LatestByTargets( return result, nil } + +func (r *approvalRepositoryImpl) DeleteByTarget( + ctx context.Context, + workflow string, + approvableID uint, +) error { + return r.DB().WithContext(ctx). + Where("approvable_type = ? AND approvable_id = ?", workflow, approvableID). + Delete(&entity.Approval{}).Error +} diff --git a/internal/database/migrations/20251104084540_purchase-items.up.sql b/internal/database/migrations/20251104084540_purchase-items.up.sql index a09b1d15..f1d239c2 100644 --- a/internal/database/migrations/20251104084540_purchase-items.up.sql +++ b/internal/database/migrations/20251104084540_purchase-items.up.sql @@ -9,13 +9,12 @@ CREATE TABLE IF NOT EXISTS purchase_items ( travel_number_docs VARCHAR, vehicle_number VARCHAR, sub_qty NUMERIC(15, 3) NOT NULL, - total_qty NUMERIC(15, 3) DEFAULT 0, - total_used NUMERIC(15, 3) DEFAULT 0, - price NUMERIC(15, 3) DEFAULT 0, - total_price NUMERIC(15, 3) DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - deleted_at TIMESTAMPTZ + total_qty NUMERIC(15, 3) NOT NULL DEFAULT 0, + total_used NUMERIC(15, 3) NOT NULL DEFAULT 0, + price NUMERIC(15, 3) NOT NULL DEFAULT 0, + total_price NUMERIC(15, 3) NOT NULL DEFAULT 0, + CONSTRAINT uq_purchase_items_purchase_product_warehouse + UNIQUE (purchase_id, product_id, warehouse_id) ); DO $$ @@ -46,14 +45,10 @@ BEGIN REFERENCES product_warehouses(id) ON DELETE SET NULL ON UPDATE CASCADE'; END IF; -END $$; -CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_items_unique_allocation - ON purchase_items (purchase_id, product_id, warehouse_id) - WHERE deleted_at IS NULL; +END $$; CREATE INDEX IF NOT EXISTS idx_purchase_items_product_id ON purchase_items (product_id); CREATE INDEX IF NOT EXISTS idx_purchase_items_warehouse_id ON purchase_items (warehouse_id); CREATE INDEX IF NOT EXISTS idx_purchase_items_product_warehouse_id ON purchase_items (product_warehouse_id); CREATE INDEX IF NOT EXISTS idx_purchase_items_purchase_id ON purchase_items (purchase_id); -CREATE INDEX IF NOT EXISTS idx_purchase_items_deleted_at ON purchase_items (deleted_at); diff --git a/internal/database/migrations/20251104084555_purchases.up.sql b/internal/database/migrations/20251104084555_purchases.up.sql index e42f1606..5c0a2197 100644 --- a/internal/database/migrations/20251104084555_purchases.up.sql +++ b/internal/database/migrations/20251104084555_purchases.up.sql @@ -1,17 +1,19 @@ CREATE TABLE IF NOT EXISTS purchases ( id BIGSERIAL PRIMARY KEY, pr_number VARCHAR NOT NULL, - po_number VARCHAR, - po_date TIMESTAMPTZ, + po_number VARCHAR NULL, + po_date TIMESTAMPTZ NULL, supplier_id BIGINT NOT NULL, - credit_term INT, + credit_term INT NOT NULL, due_date TIMESTAMPTZ, - grand_total NUMERIC(15, 3) DEFAULT 0, + grand_total NUMERIC(15, 3) NOT NULL, notes TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL + created_by BIGINT NOT NULL, + CONSTRAINT uq_purchases_pr_number UNIQUE (pr_number), + CONSTRAINT uq_purchases_po_number UNIQUE (po_number) ); DO $$ @@ -50,14 +52,6 @@ BEGIN END IF; END $$; -CREATE UNIQUE INDEX IF NOT EXISTS idx_purchases_pr_number_unique - ON purchases (pr_number) - WHERE deleted_at IS NULL; - -CREATE UNIQUE INDEX IF NOT EXISTS idx_purchases_po_number_unique - ON purchases (po_number) - WHERE deleted_at IS NULL AND po_number IS NOT NULL; - CREATE INDEX IF NOT EXISTS idx_purchases_supplier_id ON purchases (supplier_id); CREATE INDEX IF NOT EXISTS idx_purchases_created_by ON purchases (created_by); CREATE INDEX IF NOT EXISTS idx_purchases_po_date ON purchases (po_date); diff --git a/internal/entities/purchase.go b/internal/entities/purchase.go index 1a57090a..36b698b2 100644 --- a/internal/entities/purchase.go +++ b/internal/entities/purchase.go @@ -20,7 +20,8 @@ type Purchase struct { CreatedBy uint64 `gorm:"not null"` // Relations - Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"` - Items []PurchaseItem `gorm:"foreignKey:PurchaseId"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"` + Items []PurchaseItem `gorm:"foreignKey:PurchaseId"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go index b092b647..59f1a030 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -14,14 +14,11 @@ type PurchaseItem struct { TravelNumber *string TravelNumberDocs *string VehicleNumber *string - SubQty float64 `gorm:"type:numeric(15,3);not null"` - TotalQty float64 `gorm:"type:numeric(15,3);default:0"` - TotalUsed 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"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt *time.Time `gorm:"index"` + SubQty float64 `gorm:"type:numeric(15,3);not null"` + TotalQty float64 `gorm:"type:numeric(15,3);default:0"` + TotalUsed 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"` // Relations Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 599e9bc7..1eddd3b7 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" @@ -24,6 +25,8 @@ type ProductWarehouseRepository interface { ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) + CleanupEmpty(ctx context.Context, affected map[uint]struct{}) error + EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, createdBy uint64) (uint, error) } type ProductWarehouseRepositoryImpl struct { @@ -150,6 +153,73 @@ func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, d return nil } +func (r *ProductWarehouseRepositoryImpl) CleanupEmpty(ctx context.Context, affected map[uint]struct{}) error { + if len(affected) == 0 { + return nil + } + + ids := make([]uint, 0, len(affected)) + for id := range affected { + ids = append(ids, id) + } + + var emptyIDs []uint + if err := r.DB().WithContext(ctx). + Model(&entity.ProductWarehouse{}). + Where("id IN ? AND COALESCE(quantity,0) <= 0", ids). + Pluck("id", &emptyIDs).Error; err != nil { + return err + } + if len(emptyIDs) == 0 { + return nil + } + + if err := r.DB().WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("product_warehouse_id IN ?", emptyIDs). + Update("product_warehouse_id", nil).Error; err != nil { + return err + } + + if err := r.DB().WithContext(ctx). + Where("id IN ?", emptyIDs). + Delete(&entity.ProductWarehouse{}).Error; err != nil { + return err + } + + return nil +} + +func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( + ctx context.Context, + productID uint, + warehouseID uint, + createdBy uint64, +) (uint, error) { + record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) + if err == nil { + return record.Id, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, err + } + + entity := &entity.ProductWarehouse{ + ProductId: productID, + WarehouseId: warehouseID, + Quantity: 0, + CreatedBy: uint(createdBy), + } + if entity.CreatedBy == 0 { + entity.CreatedBy = 1 + } + + if err := r.CreateOne(ctx, entity, nil); err != nil { + return 0, err + } + return entity.Id, nil +} + func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) { var productWarehouse entity.ProductWarehouse err := r.DB().WithContext(ctx). diff --git a/internal/modules/master/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go index bd101be5..e1fb8d0d 100644 --- a/internal/modules/master/customers/dto/customer.dto.go +++ b/internal/modules/master/customers/dto/customer.dto.go @@ -20,7 +20,7 @@ type CustomerBaseDTO struct { AccountNumber string `json:"account_number"` Balance float64 `json:"balance"` - Pic *userDTO.UserBaseDTO `json:"pic"` + Pic *userDTO.UserBaseDTO `json:"pic,omitempty"` } type CustomerListDTO struct { diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index deed483c..0941c09d 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -14,15 +14,19 @@ type KandangBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` Status string `json:"status"` - Location *locationDTO.LocationBaseDTO `json:"location"` - Pic *userDTO.UserBaseDTO `json:"pic"` + Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` + Pic *userDTO.UserBaseDTO `json:"pic,omitempty"` } type KandangListDTO struct { - KandangBaseDTO - CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Location *locationDTO.LocationBaseDTO `json:"location"` + Pic *userDTO.UserBaseDTO `json:"pic"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type KandangDetailDTO struct { @@ -54,6 +58,18 @@ func ToKandangBaseDTO(e entity.Kandang) KandangBaseDTO { } func ToKandangListDTO(e entity.Kandang) KandangListDTO { + var location *locationDTO.LocationBaseDTO + if e.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.Location) + location = &mapped + } + + var pic *userDTO.UserBaseDTO + if e.Pic.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.Pic) + pic = &mapped + } + var createdUser *userDTO.UserBaseDTO if e.CreatedUser.Id != 0 { mapped := userDTO.ToUserBaseDTO(e.CreatedUser) @@ -61,10 +77,14 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO { } return KandangListDTO{ - KandangBaseDTO: ToKandangBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + Id: e.Id, + Name: e.Name, + Status: e.Status, + Location: location, + Pic: pic, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, } } diff --git a/internal/modules/master/locations/dto/location.dto.go b/internal/modules/master/locations/dto/location.dto.go index 6e9ec68b..d5d5b26e 100644 --- a/internal/modules/master/locations/dto/location.dto.go +++ b/internal/modules/master/locations/dto/location.dto.go @@ -14,11 +14,14 @@ type LocationBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` Address string `json:"address"` - Area *areaDTO.AreaBaseDTO `json:"area"` + Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` } type LocationListDTO struct { - LocationBaseDTO + Id uint `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Area *areaDTO.AreaBaseDTO `json:"area"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -52,11 +55,20 @@ func ToLocationListDTO(e entity.Location) LocationListDTO { createdUser = &mapped } + var area *areaDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } + return LocationListDTO{ - LocationBaseDTO: ToLocationBaseDTO(e), - CreatedUser: createdUser, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, + Id: e.Id, + Name: e.Name, + Address: e.Address, + Area: area, + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, } } diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index 9ab2270b..fb30b6cf 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -12,16 +12,17 @@ import ( // === DTO Structs === type NonstockBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - UomID uint `json:"uom_id"` + Id uint `json:"id"` + Name string `json:"name"` + Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` + Flags []string `json:"flags"` } type NonstockListDTO struct { - NonstockBaseDTO - Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` + Id uint `json:"id"` + Name string `json:"name"` + Uom *uomDTO.UomBaseDTO `json:"uom"` Suppliers []supplierDTO.SupplierBaseDTO `json:"suppliers"` - Flags []string `json:"flags"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -35,10 +36,22 @@ type NonstockDetailDTO struct { // === Mapper Functions === func ToNonstockBaseDTO(e entity.Nonstock) NonstockBaseDTO { + var uomRef *uomDTO.UomBaseDTO + if e.Uom.Id != 0 { + mapped := uomDTO.ToUomBaseDTO(e.Uom) + uomRef = &mapped + } + + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + return NonstockBaseDTO{ Id: e.Id, Name: e.Name, - UomID: e.UomId, + Uom: uomRef, + Flags: flags, } } @@ -66,13 +79,13 @@ func ToNonstockListDTO(e entity.Nonstock) NonstockListDTO { } return NonstockListDTO{ - NonstockBaseDTO: ToNonstockBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, - Uom: uomRef, - Suppliers: suppliers, - Flags: flags, + Id: e.Id, + Name: e.Name, + Uom: uomRef, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + Suppliers: suppliers, } } diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go index b7f21008..60696fc7 100644 --- a/internal/modules/master/products/dto/product.dto.go +++ b/internal/modules/master/products/dto/product.dto.go @@ -13,8 +13,10 @@ import ( // === DTO Structs === type ProductBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint `json:"id"` + Name string `json:"name"` + Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` + Flags []string `json:"flags"` } type ProductListDTO struct { @@ -25,10 +27,8 @@ type ProductListDTO struct { SellingPrice *float64 `json:"selling_price,omitempty"` Tax *float64 `json:"tax,omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty"` - Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryBaseDTO `json:"product_category,omitempty"` Suppliers []supplierDTO.SupplierBaseDTO `json:"suppliers"` - Flags []string `json:"flags"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -42,9 +42,22 @@ type ProductDetailDTO struct { // === Mapper Functions === func ToProductBaseDTO(e entity.Product) ProductBaseDTO { + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + + var uomRef *uomDTO.UomBaseDTO + if e.Uom.Id != 0 { + mapped := uomDTO.ToUomBaseDTO(e.Uom) + uomRef = &mapped + } + return ProductBaseDTO{ - Id: e.Id, - Name: e.Name, + Id: e.Id, + Name: e.Name, + Flags: flags, + Uom: uomRef, } } @@ -55,12 +68,6 @@ func ToProductListDTO(e entity.Product) ProductListDTO { createdUser = &mapped } - var uomRef *uomDTO.UomBaseDTO - if e.Uom.Id != 0 { - mapped := uomDTO.ToUomBaseDTO(e.Uom) - uomRef = &mapped - } - var categoryRef *productCategoryDTO.ProductCategoryBaseDTO if e.ProductCategory.Id != 0 { mapped := productCategoryDTO.ToProductCategoryBaseDTO(e.ProductCategory) @@ -72,11 +79,6 @@ func ToProductListDTO(e entity.Product) ProductListDTO { suppliers[i] = supplierDTO.ToSupplierBaseDTO(s) } - flags := make([]string, len(e.Flags)) - for i, f := range e.Flags { - flags[i] = f.Name - } - return ProductListDTO{ Brand: e.Brand, Sku: e.Sku, @@ -88,10 +90,8 @@ func ToProductListDTO(e entity.Product) ProductListDTO { CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, - Uom: uomRef, ProductCategory: categoryRef, Suppliers: suppliers, - Flags: flags, } } diff --git a/internal/modules/master/products/repositories/product.repository.go b/internal/modules/master/products/repositories/product.repository.go index 06672f5f..d1cc5cb2 100644 --- a/internal/modules/master/products/repositories/product.repository.go +++ b/internal/modules/master/products/repositories/product.repository.go @@ -16,6 +16,7 @@ type ProductRepository interface { IdExists(ctx context.Context, id uint) (bool, error) CategoryExists(ctx context.Context, categoryID uint) (bool, error) GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) + IsLinkedToSupplier(ctx context.Context, productID, supplierID uint) (bool, error) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIDs []uint) error SyncFlags(ctx context.Context, tx *gorm.DB, productID uint, flags []string) error DeleteFlags(ctx context.Context, tx *gorm.DB, productID uint) error @@ -90,6 +91,17 @@ func (r *ProductRepositoryImpl) GetSuppliersByIDs(ctx context.Context, supplierI return suppliers, nil } +func (r *ProductRepositoryImpl) IsLinkedToSupplier(ctx context.Context, productID, supplierID uint) (bool, error) { + var count int64 + if err := r.DB().WithContext(ctx). + Model(&entity.ProductSupplier{}). + Where("product_id = ? AND supplier_id = ?", productID, supplierID). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + func (r *ProductRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIDs []uint) error { db := tx if db == nil { diff --git a/internal/modules/master/warehouses/dto/warehouse.dto.go b/internal/modules/master/warehouses/dto/warehouse.dto.go index b5432127..f3c3715b 100644 --- a/internal/modules/master/warehouses/dto/warehouse.dto.go +++ b/internal/modules/master/warehouses/dto/warehouse.dto.go @@ -16,16 +16,21 @@ type WarehouseBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` Type string `json:"type"` - Area *areaDTO.AreaBaseDTO `json:"area"` - Location *locationDTO.LocationBaseDTO `json:"location"` - Kandang *kandangDTO.KandangBaseDTO `json:"kandang"` + Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` + Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` } type WarehouseListDTO struct { - WarehouseBaseDTO - CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Area *areaDTO.AreaBaseDTO `json:"area"` + Location *locationDTO.LocationBaseDTO `json:"location"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type WarehouseDetailDTO struct { @@ -70,11 +75,34 @@ func ToWarehouseListDTO(e entity.Warehouse) WarehouseListDTO { createdUser = &mapped } + var area *areaDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } + + var location *locationDTO.LocationBaseDTO + if e.Location != nil && e.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(*e.Location) + location = &mapped + } + + var kandang *kandangDTO.KandangBaseDTO + if e.Kandang != nil && e.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangBaseDTO(*e.Kandang) + kandang = &mapped + } + return WarehouseListDTO{ - WarehouseBaseDTO: ToWarehouseBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + Id: e.Id, + Name: e.Name, + Type: e.Type, + Area: area, + Location: location, + Kandang: kandang, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, } } diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index ffef2f5d..d10f42af 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -1,7 +1,10 @@ package controller import ( + "fmt" + "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" @@ -19,6 +22,74 @@ func NewPurchaseController(s service.PurchaseService) *PurchaseController { return &PurchaseController{service: s} } +func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { + query := &validation.PurchaseQuery{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: strings.TrimSpace(c.Query("search")), + PrNumber: strings.TrimSpace(c.Query("pr_number")), + CreatedFrom: strings.TrimSpace(c.Query("created_from")), + CreatedTo: strings.TrimSpace(c.Query("created_to")), + } + + if supplierID := c.QueryInt("supplier_id", 0); supplierID > 0 { + query.SupplierID = uint(supplierID) + } + + if status := strings.TrimSpace(c.Query("status")); status != "" { + query.Status = strings.ToUpper(status) + } + + results, total, err := ctrl.service.GetAll(c, query) + if err != nil { + return err + } + + limit := query.Limit + if limit <= 0 { + limit = 10 + } + page := query.Page + if page <= 0 { + page = 1 + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.PurchaseListItemDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase fetched successfully", + Meta: response.Meta{ + Page: page, + Limit: limit, + TotalPages: int64(math.Ceil(float64(total) / float64(limit))), + TotalResults: total, + }, + Data: dto.ToPurchaseListDTOs(results), + }) +} + +func (ctrl *PurchaseController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + id, err := strconv.ParseUint(param, 10, 64) + if err != nil || id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + } + + result, err := ctrl.service.GetOne(c, id) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase fetched successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { req := new(validation.CreatePurchaseRequest) @@ -35,7 +106,7 @@ func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { JSON(response.Success{ Code: fiber.StatusCreated, Status: "success", - Message: "Purchase requisition created successfully", + Message: "Purchase created successfully", Data: dto.ToPurchaseDetailDTO(*result), }) } @@ -44,12 +115,12 @@ func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { param := c.Params("id") id, err := strconv.ParseUint(param, 10, 64) if err != nil || id == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase requisition id") + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } req := new(validation.ApproveStaffPurchaseRequest) if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) } result, err := ctrl.service.ApproveStaffPurchase(c, id, req) @@ -65,3 +136,102 @@ func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { Data: dto.ToPurchaseDetailDTO(*result), }) } + + +func (ctrl *PurchaseController) ApproveManagerPurchase(c *fiber.Ctx) error { + param := c.Params("id") + id, err := strconv.ParseUint(param, 10, 64) + if err != nil || id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + } + + req := new(validation.ApproveManagerPurchaseRequest) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.ApproveManagerPurchase(c, id, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Manager purchase approval recorded successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + +func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { + param := c.Params("id") + id, err := strconv.ParseUint(param, 10, 64) + if err != nil || id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + } + + req := new(validation.ReceivePurchaseRequest) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.ReceiveProducts(c, id, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase receiving recorded successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + +func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { + param := c.Params("id") + id, err := strconv.ParseUint(param, 10, 64) + if err != nil || id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + } + + req := new(validation.DeletePurchaseItemsRequest) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.DeleteItems(c, id, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase items deleted successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + +func (ctrl *PurchaseController) DeletePurchase(c *fiber.Ctx) error { + param := c.Params("id") + id, err := strconv.ParseUint(param, 10, 64) + if err != nil || id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + } + + if err := ctrl.service.DeletePurchase(c, id); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Purchase deleted successfully", + Data: nil, + }) +} diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index 381115a6..5a2926cf 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -4,129 +4,94 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" ) -type SupplierBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Alias string `json:"alias"` - Type string `json:"type"` - Category string `json:"category"` -} - -type AreaBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type WarehouseBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Area *AreaBaseDTO `json:"area,omitempty"` - Location *LocationBaseDTO `json:"location,omitempty"` -} - -type ProductBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - SKU *string `json:"sku,omitempty"` -} - -type PurchaseItemDTO struct { - Id uint64 `json:"id"` - Product *ProductBaseDTO `json:"product,omitempty"` - Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` - ProductWarehouseID *uint64 `json:"product_warehouse_id,omitempty"` - SubQty float64 `json:"sub_qty"` - TotalQty float64 `json:"total_qty"` - TotalUsed float64 `json:"total_used"` - Price float64 `json:"price"` - TotalPrice float64 `json:"total_price"` +type PurchaseListItemDTO struct { + Id uint64 `json:"id"` + PrNumber string `json:"pr_number"` + PoNumber *string `json:"po_number"` + Supplier *supplierDTO.SupplierBaseDTO `json:"supplier"` + CreditTerm *int `json:"credit_term"` + DueDate *time.Time `json:"due_date"` + PoDate *time.Time `json:"po_date"` + GrandTotal float64 `json:"grand_total"` + Notes *string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval *approvalDTO.ApprovalBaseDTO `json:"approval"` } type PurchaseDetailDTO struct { - Id uint64 `json:"id"` - PrNumber string `json:"pr_number"` - Supplier *SupplierBaseDTO `json:"supplier,omitempty"` - CreditTerm *int `json:"credit_term,omitempty"` - DueDate *time.Time `json:"due_date,omitempty"` - GrandTotal float64 `json:"grand_total"` - Notes *string `json:"notes,omitempty"` - Items []PurchaseItemDTO `json:"items"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id uint64 `json:"id"` + PrNumber string `json:"pr_number"` + PoNumber *string `json:"po_number"` + Supplier *supplierDTO.SupplierBaseDTO `json:"supplier"` + CreditTerm *int `json:"credit_term"` + DueDate *time.Time `json:"due_date"` + PoDate *time.Time `json:"po_date"` + GrandTotal float64 `json:"grand_total"` + Notes *string `json:"notes"` + Items []PurchaseItemDTO `json:"items"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval *approvalDTO.ApprovalBaseDTO `json:"approval"` } -func toSupplierBaseDTO(s entity.Supplier) *SupplierBaseDTO { - if s.Id == 0 { - return nil - } - return &SupplierBaseDTO{ - Id: s.Id, - Name: s.Name, - Alias: s.Alias, - Type: s.Type, - Category: s.Category, - } -} - -func toWarehouseBaseDTO(w *entity.Warehouse) *WarehouseBaseDTO { - if w == nil || w.Id == 0 { - return nil - } - dto := &WarehouseBaseDTO{ - Id: w.Id, - Name: w.Name, - } - if w.Area.Id != 0 { - dto.Area = &AreaBaseDTO{ - Id: w.Area.Id, - Name: w.Area.Name, - } - } - if w.Location != nil && w.Location.Id != 0 { - dto.Location = &LocationBaseDTO{ - Id: w.Location.Id, - Name: w.Location.Name, - } - } - return dto -} - -func toProductBaseDTO(p *entity.Product) *ProductBaseDTO { - if p == nil || p.Id == 0 { - return nil - } - dto := &ProductBaseDTO{ - Id: p.Id, - Name: p.Name, - } - if p.Sku != nil { - dto.SKU = p.Sku - } - return dto +type PurchaseItemDTO struct { + Id uint64 `json:"id"` + ProductID uint64 `json:"product_id"` + Product *productDTO.ProductBaseDTO `json:"product"` + WarehouseID uint64 `json:"warehouse_id"` + Warehouse *warehouseDTO.WarehouseBaseDTO `json:"warehouse"` + ProductWarehouseID *uint64 `json:"product_warehouse_id"` + SubQty float64 `json:"sub_qty"` + TotalQty float64 `json:"total_qty"` + TotalUsed float64 `json:"total_used"` + Price float64 `json:"price"` + TotalPrice float64 `json:"total_price"` + ReceivedDate *time.Time `json:"received_date"` + TravelNumber *string `json:"travel_number"` + TravelDocumentPath *string `json:"travel_document_path"` + VehicleNumber *string `json:"vehicle_number"` } func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { dto := PurchaseItemDTO{ Id: item.Id, + ProductID: item.ProductId, ProductWarehouseID: item.ProductWarehouseId, + WarehouseID: item.WarehouseId, SubQty: item.SubQty, TotalQty: item.TotalQty, TotalUsed: item.TotalUsed, Price: item.Price, TotalPrice: item.TotalPrice, + ReceivedDate: item.ReceivedDate, + TravelNumber: item.TravelNumber, + TravelDocumentPath: item.TravelNumberDocs, + VehicleNumber: item.VehicleNumber, } - if item.Product != nil { - dto.Product = toProductBaseDTO(item.Product) + if item.Product != nil && item.Product.Id != 0 { + summary := productDTO.ToProductBaseDTO(*item.Product) + dto.Product = &summary } - if item.Warehouse != nil { - dto.Warehouse = toWarehouseBaseDTO(item.Warehouse) + if item.Warehouse != nil && item.Warehouse.Id != 0 { + summary := warehouseDTO.ToWarehouseBaseDTO(*item.Warehouse) + if item.Warehouse.Area.Id != 0 { + areaSummary := areaDTO.ToAreaBaseDTO(item.Warehouse.Area) + summary.Area = &areaSummary + } + if item.Warehouse.Location != nil && item.Warehouse.Location.Id != 0 { + locationSummary := locationDTO.ToLocationBaseDTO(*item.Warehouse.Location) + summary.Location = &locationSummary + } + dto.Warehouse = &summary } return dto } @@ -140,16 +105,69 @@ func ToPurchaseItemDTOs(items []entity.PurchaseItem) []PurchaseItemDTO { } func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO { - return PurchaseDetailDTO{ + dto := PurchaseDetailDTO{ Id: p.Id, PrNumber: p.PrNumber, - Supplier: toSupplierBaseDTO(p.Supplier), + PoNumber: p.PoNumber, + Supplier: mapSupplier(p.Supplier), CreditTerm: p.CreditTerm, DueDate: p.DueDate, + PoDate: p.PoDate, GrandTotal: p.GrandTotal, Notes: p.Notes, Items: ToPurchaseItemDTOs(p.Items), CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, } + if approval := toPurchaseApprovalDTO(p); approval != nil { + dto.Approval = approval + } + return dto +} + +func ToPurchaseListDTO(p entity.Purchase) PurchaseListItemDTO { + dto := PurchaseListItemDTO{ + Id: p.Id, + PrNumber: p.PrNumber, + PoNumber: p.PoNumber, + Supplier: mapSupplier(p.Supplier), + CreditTerm: p.CreditTerm, + DueDate: p.DueDate, + PoDate: p.PoDate, + GrandTotal: p.GrandTotal, + Notes: p.Notes, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } + if approval := toPurchaseApprovalDTO(p); approval != nil { + dto.Approval = approval + } + return dto +} + +func mapSupplier(s entity.Supplier) *supplierDTO.SupplierBaseDTO { + if s.Id == 0 { + return nil + } + summary := supplierDTO.ToSupplierBaseDTO(s) + return &summary +} + +func ToPurchaseListDTOs(items []entity.Purchase) []PurchaseListItemDTO { + if len(items) == 0 { + return nil + } + result := make([]PurchaseListItemDTO, len(items)) + for i, item := range items { + result[i] = ToPurchaseListDTO(item) + } + return result +} + +func toPurchaseApprovalDTO(p entity.Purchase) *approvalDTO.ApprovalBaseDTO { + if p.LatestApproval == nil || p.LatestApproval.Id == 0 { + return nil + } + mapped := approvalDTO.ToApprovalDTO(*p.LatestApproval) + return &mapped } diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index 1397f27e..de79a0c9 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -1,13 +1,50 @@ package purchases import ( + "fmt" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) type PurchaseModule struct{} func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - RegisterRoutes(router, db, validate) + purchaseRepo := rPurchase.NewPurchaseRepository(db) + productRepo := rProduct.NewProductRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + supplierRepo := rSupplier.NewSupplierRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) + } + expenseBridge := service.NewNoopPurchaseExpenseBridge() + + purchaseService := service.NewPurchaseService( + validate, + purchaseRepo, + productRepo, + warehouseRepo, + supplierRepo, + productWarehouseRepo, + approvalRepo, + approvalService, + expenseBridge, + ) + + Routes(router, purchaseService) } diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index 398fcea1..bc1c038a 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -3,17 +3,31 @@ package repositories import ( "context" "errors" + "fmt" + "strconv" + "strings" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type PurchaseRepository interface { repository.BaseRepository[entity.Purchase] CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error + CreateItems(ctx context.Context, purchaseID uint64, items []*entity.PurchaseItem) error GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) + GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) UpdatePricing(ctx context.Context, purchaseID uint64, updates []PurchasePricingUpdate, grandTotal float64) error + UpdateReceivingDetails(ctx context.Context, purchaseID uint64, updates []PurchaseReceivingUpdate) error + DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) error + WithListRelations() func(*gorm.DB) *gorm.DB + UpdateGrandTotal(ctx context.Context, purchaseID uint64, grandTotal float64) error + NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) + NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) } type PurchaseRepositoryImpl struct { @@ -26,6 +40,16 @@ func NewPurchaseRepository(db *gorm.DB) PurchaseRepository { } } +type PurchaseListFilter struct { + SupplierID uint + Search string + PrNumber string + CreatedFrom *time.Time + CreatedTo *time.Time + Status *entity.ApprovalAction + CompletedOnly bool +} + func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error { db := r.DB().WithContext(ctx) @@ -47,9 +71,47 @@ func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase * return nil } +func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint64, items []*entity.PurchaseItem) error { + if len(items) == 0 { + return nil + } + + for _, item := range items { + if item == nil { + continue + } + item.PurchaseId = purchaseID + } + + return r.DB().WithContext(ctx).Create(&items).Error +} + func (r *PurchaseRepositoryImpl) GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) { var purchase entity.Purchase err := r.DB().WithContext(ctx). + Scopes(r.withDetailRelations). + First(&purchase, id).Error + if err != nil { + return nil, err + } + return &purchase, nil +} + +func (r *PurchaseRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + db = r.withListRelations(db) + return r.applyListFilters(db, filter) + }) +} + +func (r *PurchaseRepositoryImpl) WithListRelations() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return r.withListRelations(db) + } +} + +func (r *PurchaseRepositoryImpl) withDetailRelations(db *gorm.DB) *gorm.DB { + return db. Preload("Supplier"). Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("id ASC") @@ -58,18 +120,34 @@ func (r *PurchaseRepositoryImpl) GetByIDWithRelations(ctx context.Context, id ui Preload("Items.Warehouse"). Preload("Items.Warehouse.Area"). Preload("Items.Warehouse.Location"). - Preload("Items.ProductWarehouse"). - First(&purchase, id).Error - if err != nil { - return nil, err + Preload("Items.ProductWarehouse") +} + +func (r *PurchaseRepositoryImpl) WithDetailRelations() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return r.withDetailRelations(db) } - return &purchase, nil } type PurchasePricingUpdate struct { ItemID uint64 + ProductID *uint64 Price float64 TotalPrice float64 + Quantity *float64 + TotalQty *float64 +} + +type PurchaseReceivingUpdate struct { + ItemID uint64 + ReceivedDate *time.Time + TravelNumber *string + TravelDocumentPath *string + VehicleNumber *string + ReceivedQty *float64 + WarehouseID *uint + ProductWarehouseID *uint + ClearProductWarehouse bool } func (r *PurchaseRepositoryImpl) UpdatePricing( @@ -85,13 +163,23 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( db := r.DB().WithContext(ctx) for _, upd := range updates { + data := map[string]interface{}{ + "price": upd.Price, + "total_price": upd.TotalPrice, + } + if upd.ProductID != nil { + data["product_id"] = *upd.ProductID + } + if upd.Quantity != nil { + data["sub_qty"] = *upd.Quantity + } + if upd.TotalQty != nil { + data["total_qty"] = *upd.TotalQty + } + result := db.Model(&entity.PurchaseItem{}). Where("purchase_id = ? AND id = ?", purchaseID, upd.ItemID). - Updates(map[string]interface{}{ - "price": upd.Price, - "total_price": upd.TotalPrice, - "updated_at": gorm.Expr("NOW()"), - }) + Updates(data) if result.Error != nil { return result.Error } @@ -111,3 +199,225 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( return nil } + +func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( + ctx context.Context, + purchaseID uint64, + updates []PurchaseReceivingUpdate, +) error { + if len(updates) == 0 { + return errors.New("receiving updates cannot be empty") + } + + db := r.DB().WithContext(ctx) + + for _, upd := range updates { + data := map[string]interface{}{} + + if upd.ReceivedDate != nil { + data["received_date"] = upd.ReceivedDate + } + if upd.TravelNumber != nil { + data["travel_number"] = upd.TravelNumber + } + if upd.TravelDocumentPath != nil { + data["travel_number_docs"] = upd.TravelDocumentPath + } + if upd.VehicleNumber != nil { + data["vehicle_number"] = upd.VehicleNumber + } + if upd.ReceivedQty != nil { + data["total_qty"] = upd.ReceivedQty + } + if upd.WarehouseID != nil && *upd.WarehouseID != 0 { + data["warehouse_id"] = upd.WarehouseID + } + + if upd.ProductWarehouseID != nil { + data["product_warehouse_id"] = *upd.ProductWarehouseID + } else if upd.ClearProductWarehouse { + data["product_warehouse_id"] = gorm.Expr("NULL") + } + + if len(data) == 0 { + continue + } + + result := db.Model(&entity.PurchaseItem{}). + Where("purchase_id = ? AND id = ?", purchaseID, upd.ItemID). + Updates(data) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + } + + return nil +} + +func (r *PurchaseRepositoryImpl) UpdateGrandTotal( + ctx context.Context, + purchaseID uint64, + grandTotal float64, +) error { + return r.DB().WithContext(ctx). + Model(&entity.Purchase{}). + Where("id = ?", purchaseID). + Updates(map[string]interface{}{ + "grand_total": grandTotal, + "updated_at": gorm.Expr("NOW()"), + }).Error +} + +func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) error { + if len(itemIDs) == 0 { + return errors.New("itemIDs cannot be empty") + } + + return r.DB().WithContext(ctx). + Where("purchase_id = ? AND id IN ?", purchaseID, itemIDs). + Delete(&entity.PurchaseItem{}).Error +} + +func (r *PurchaseRepositoryImpl) NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) { + return r.generateSequentialNumber(ctx, tx, "pr_number", utils.PurchasePRNumberPrefix, utils.PurchaseNumberPadding) +} + +func (r *PurchaseRepositoryImpl) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) { + return r.generateSequentialNumber(ctx, tx, "po_number", utils.PurchasePONumberPrefix, utils.PurchaseNumberPadding) +} + +func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) { + db := tx + if db == nil { + db = r.DB() + } + + var values []string + err := db.WithContext(ctx). + Model(&entity.Purchase{}). + Where(fmt.Sprintf("%s LIKE ?", column), prefix+"%"). + Select(column). + Order(fmt.Sprintf("%s DESC", column)). + Limit(20). + Clauses(clause.Locking{Strength: "UPDATE"}). + Pluck(column, &values).Error + if err != nil { + return "", err + } + + next := 1 + for _, value := range values { + if number, ok := parseNumericSuffix(value, prefix); ok { + next = number + 1 + break + } + } + + const maxAttempts = 20 + for attempt := 0; attempt < maxAttempts; attempt++ { + candidate := fmt.Sprintf("%s%0*d", prefix, padding, next) + exists, err := r.numberExists(ctx, db, column, candidate) + if err != nil { + return "", err + } + if !exists { + return candidate, nil + } + next++ + } + + return "", fmt.Errorf("unable to generate unique %s", column) +} + +func (r *PurchaseRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, column, value string) (bool, error) { + var count int64 + if err := db.WithContext(ctx). + Model(&entity.Purchase{}). + Where(fmt.Sprintf("%s = ?", column), value). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func parseNumericSuffix(value, prefix string) (int, bool) { + if !strings.HasPrefix(value, prefix) { + return 0, false + } + suffix := strings.TrimPrefix(value, prefix) + if suffix == "" { + return 0, false + } + trimmed := strings.TrimLeft(suffix, "0") + if trimmed == "" { + trimmed = "0" + } + number, err := strconv.Atoi(trimmed) + if err != nil { + return 0, false + } + return number, true +} + +func (r *PurchaseRepositoryImpl) withListRelations(db *gorm.DB) *gorm.DB { + return db.Preload("Supplier") +} + +func (r *PurchaseRepositoryImpl) applyListFilters(db *gorm.DB, filter *PurchaseListFilter) *gorm.DB { + if filter == nil { + return db + } + + if filter.SupplierID > 0 { + db = db.Where("purchases.supplier_id = ?", filter.SupplierID) + } + + if search := strings.ToLower(strings.TrimSpace(filter.Search)); search != "" { + like := "%" + search + "%" + db = db.Where("(LOWER(purchases.pr_number) LIKE ? OR LOWER(COALESCE(purchases.notes, '')) LIKE ?)", like, like) + } + + if pr := strings.TrimSpace(filter.PrNumber); pr != "" { + db = db.Where("purchases.pr_number ILIKE ?", "%"+pr+"%") + } + + if filter.CreatedFrom != nil { + db = db.Where("purchases.created_at >= ?", *filter.CreatedFrom) + } + + if filter.CreatedTo != nil { + db = db.Where("purchases.created_at < ?", *filter.CreatedTo) + } + + if filter.CompletedOnly { + step := uint16(utils.PurchaseStepCompleted) + db = r.applyLatestApprovalFilter(db, entity.ApprovalActionApproved, &step) + } else if filter.Status != nil { + db = r.applyLatestApprovalFilter(db, *filter.Status, nil) + } + + return db.Order("purchases.created_at DESC").Order("purchases.id DESC") +} + +func (r *PurchaseRepositoryImpl) applyLatestApprovalFilter(db *gorm.DB, action entity.ApprovalAction, minStep *uint16) *gorm.DB { + latestSub := r.DB(). + Model(&entity.Approval{}). + Select("approvable_id, MAX(action_at) AS latest_action_at"). + Where("approvable_type = ?", utils.ApprovalWorkflowPurchase.String()). + Group("approvable_id") + + db = db. + Joins("LEFT JOIN (?) AS latest_purchase_approvals ON latest_purchase_approvals.approvable_id = purchases.id", latestSub). + Joins( + "LEFT JOIN approvals ON approvals.approvable_id = purchases.id AND approvals.approvable_type = ? AND approvals.action_at = latest_purchase_approvals.latest_action_at", + utils.ApprovalWorkflowPurchase.String(), + ). + Where("approvals.action = ?", string(action)) + if minStep != nil { + db = db.Where("approvals.step_number >= ?", *minStep) + } + return db +} diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go index df3ea1a1..41706b01 100644 --- a/internal/modules/purchases/route.go +++ b/internal/modules/purchases/route.go @@ -1,59 +1,22 @@ package purchases import ( - "fmt" - - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/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" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/controllers" - rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - utils "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gorm.io/gorm" ) -func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - group := router.Group("/purchases") +func Routes(router fiber.Router, purchaseService service.PurchaseService) { + ctrl := controller.NewPurchaseController(purchaseService) - purchaseRepo := rPurchase.NewPurchaseRepository(db) - productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - warehouseRepo := rWarehouse.NewWarehouseRepository(db) - supplierRepo := rSupplier.NewSupplierRepository(db) - userRepo := rUser.NewUserRepository(db) - - approvalRepo := commonRepo.NewApprovalRepository(db) - approvalService := commonSvc.NewApprovalService(approvalRepo) - if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) - } - - purchaseService := service.NewPurchaseService( - validate, - purchaseRepo, - productWarehouseRepo, - warehouseRepo, - supplierRepo, - approvalRepo, - ) - userService := sUser.NewUserService(userRepo, validate) - - PurchaseRoutes(group, userService, purchaseService) -} - -func PurchaseRoutes(v1 fiber.Router, u sUser.UserService, s service.PurchaseService) { - ctrl := controller.NewPurchaseController(s) - - route := v1.Group("/requisitions") - - // route.Post("/", m.Auth(u), ctrl.CreateOne) + route := router.Group("/purchases") + route.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) route.Post("/", ctrl.CreateOne) route.Post("/:id/approvals/staff", ctrl.ApproveStaffPurchase) + route.Post("/:id/approvals/manager", ctrl.ApproveManagerPurchase) + route.Post("/:id/receipts", ctrl.ReceiveProducts) + route.Delete("/:id", ctrl.DeletePurchase) + route.Delete("/:id/items", ctrl.DeleteItems) } diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go new file mode 100644 index 00000000..b7c96d03 --- /dev/null +++ b/internal/modules/purchases/services/expense_bridge.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +// PurchaseExpenseBridge defines hooks that allow purchase flows to stay in sync with expense data once it exists. +type PurchaseExpenseBridge interface { + OnItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) error + OnItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) error + OnItemsReceived(ctx context.Context, purchaseID uint64, updates []ExpenseReceivingPayload) error +} + +// ExpenseReceivingPayload captures the minimum data expense integration will need once available. +type ExpenseReceivingPayload struct { + PurchaseItemID uint64 + ProductID uint64 + WarehouseID uint64 + ReceivedQty float64 + ReceivedDate *time.Time +} + +// noopPurchaseExpenseBridge is the default implementation until the expense module is ready. +type noopPurchaseExpenseBridge struct{} + +func NewNoopPurchaseExpenseBridge() PurchaseExpenseBridge { + return &noopPurchaseExpenseBridge{} +} + +func (n *noopPurchaseExpenseBridge) OnItemsCreated(_ context.Context, _ uint64, _ []entity.PurchaseItem) error { + return nil +} + +func (n *noopPurchaseExpenseBridge) OnItemsDeleted(_ context.Context, _ uint64, _ []uint64) error { + return nil +} + +func (n *noopPurchaseExpenseBridge) OnItemsReceived(_ context.Context, _ uint64, _ []ExpenseReceivingPayload) error { + return nil +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index a7419fc5..2082e195 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -1,63 +1,165 @@ package service import ( + "context" "errors" "fmt" + "math" + "strings" "time" 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" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "github.com/google/uuid" "github.com/sirupsen/logrus" "gorm.io/gorm" ) type PurchaseService interface { + GetAll(ctx *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) + GetOne(ctx *fiber.Ctx, id uint64) (*entity.Purchase, error) CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) ApproveStaffPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) + ApproveManagerPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) + ReceiveProducts(ctx *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) + DeleteItems(ctx *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) + DeletePurchase(ctx *fiber.Ctx, id uint64) error } +const ( + priceTolerance = 0.0001 + queryDateLayout = "2006-01-02" +) + type purchaseService struct { Log *logrus.Logger Validate *validator.Validate PurchaseRepo rPurchase.PurchaseRepository - ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProductRepo rProduct.ProductRepository WarehouseRepo rWarehouse.WarehouseRepository SupplierRepo rSupplier.SupplierRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository ApprovalRepo commonRepo.ApprovalRepository + ApprovalSvc commonSvc.ApprovalService + ExpenseBridge PurchaseExpenseBridge +} + +type staffAdjustmentPayload struct { + PricingUpdates []rPurchase.PurchasePricingUpdate + NewItems []*entity.PurchaseItem + GrandTotal float64 } func NewPurchaseService( validate *validator.Validate, purchaseRepo rPurchase.PurchaseRepository, - productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + productRepo rProduct.ProductRepository, warehouseRepo rWarehouse.WarehouseRepository, supplierRepo rSupplier.SupplierRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, approvalRepo commonRepo.ApprovalRepository, + approvalSvc commonSvc.ApprovalService, + expenseBridge PurchaseExpenseBridge, ) PurchaseService { + if expenseBridge == nil { + expenseBridge = NewNoopPurchaseExpenseBridge() + } return &purchaseService{ Log: utils.Log, Validate: validate, PurchaseRepo: purchaseRepo, - ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, WarehouseRepo: warehouseRepo, SupplierRepo: supplierRepo, + ProductWarehouseRepo: productWarehouseRepo, ApprovalRepo: approvalRepo, + ApprovalSvc: approvalSvc, + ExpenseBridge: expenseBridge, } } -func uint64Ptr(v uint64) *uint64 { - return &v +func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + limit := params.Limit + if limit <= 0 { + limit = 10 + } + page := params.Page + if page <= 0 { + page = 1 + } + offset := (page - 1) * limit + + ctx := c.Context() + + createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo) + if err != nil { + return nil, 0, err + } + + statusAction, completedOnly, err := parseApprovalAction(params.Status) + if err != nil { + return nil, 0, err + } + + filter := &rPurchase.PurchaseListFilter{ + SupplierID: params.SupplierID, + Search: params.Search, + PrNumber: params.PrNumber, + CreatedFrom: createdFrom, + CreatedTo: createdTo, + Status: statusAction, + CompletedOnly: completedOnly, + } + + purchases, total, err := s.PurchaseRepo.GetAllWithFilters(ctx, offset, limit, filter) + if err != nil { + s.Log.Errorf("Failed to get purchases: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchases") + } + + if err := s.attachLatestApprovals(ctx, purchases); err != nil { + s.Log.Warnf("Unable to attach latest approvals to purchases: %+v", err) + } + + return purchases, total, nil +} + +func (s *purchaseService) GetOne(c *fiber.Ctx, id uint64) (*entity.Purchase, error) { + if id == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + } + + ctx := c.Context() + + purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + } + s.Log.Errorf("Failed to get purchase: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) + } + + return purchase, nil } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { @@ -65,8 +167,9 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return nil, err } - supplier, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil) - if err != nil { + ctx := c.Context() + + if _, err := s.SupplierRepo.GetByID(ctx, req.SupplierID, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") } @@ -74,73 +177,60 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") } - warehouse, err := s.WarehouseRepo.GetDetailByID(c.Context(), req.WarehouseID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") - } - s.Log.Errorf("Failed to get warehouse: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") - } - - if warehouse.AreaId != req.AreaID { - return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse does not belong to the provided area") - } - if warehouse.LocationId == nil || *warehouse.LocationId != req.LocationID { - return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse does not belong to the provided location") - } - type aggregatedItem struct { - productId uint64 - warehouseId uint64 - productWarehouseId *uint64 - subQty float64 + productId uint64 + warehouseId uint64 + subQty float64 } if len(req.Items) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") } + warehouseCache := make(map[uint]*entity.Warehouse) + productSupplierCache := make(map[uint]bool) + + getWarehouse := func(id uint) (*entity.Warehouse, error) { + if warehouse, ok := warehouseCache[id]; ok { + return warehouse, nil + } + + warehouse, err := s.WarehouseRepo.GetDetailByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) + } + s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + } + + warehouseCache[id] = warehouse + return warehouse, nil + } + aggregated := make([]*aggregatedItem, 0, len(req.Items)) indexMap := make(map[string]int) for _, item := range req.Items { - var ( - productId = uint64(item.ProductID) - warehouseId = uint64(req.WarehouseID) - productWarehouseId *uint64 - ) - - if item.ProductWarehouseID != nil { - productWarehouse, err := s.ProductWarehouseRepo.GetDetailByID(c.Context(), *item.ProductWarehouseID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", *item.ProductWarehouseID)) - } - s.Log.Errorf("Failed to get product warehouse %d: %+v", *item.ProductWarehouseID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") - } - - if productWarehouse.WarehouseId != req.WarehouseID { - return nil, fiber.NewError(fiber.StatusBadRequest, "Product warehouse does not match selected warehouse") - } - - productId = uint64(productWarehouse.ProductId) - warehouseId = uint64(productWarehouse.WarehouseId) - idCopy := uint64(productWarehouse.Id) - productWarehouseId = &idCopy - } else { - productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), item.ProductID, req.WarehouseID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to get product warehouse for product %d and warehouse %d: %+v", item.ProductID, req.WarehouseID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") - } - if err == nil { - idCopy := uint64(productWarehouse.Id) - productWarehouseId = &idCopy - } + if _, err := getWarehouse(item.WarehouseID); err != nil { + return nil, err } + if _, checked := productSupplierCache[item.ProductID]; !checked { + linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, item.ProductID, req.SupplierID) + if err != nil { + s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + } + if !linked { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID)) + } + productSupplierCache[item.ProductID] = true + } + + productId := uint64(item.ProductID) + warehouseId := uint64(item.WarehouseID) + key := fmt.Sprintf("%d:%d", productId, warehouseId) if idx, ok := indexMap[key]; ok { aggregated[idx].subQty += item.Quantity @@ -148,29 +238,20 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase } entry := &aggregatedItem{ - productId: productId, - warehouseId: warehouseId, - productWarehouseId: productWarehouseId, - subQty: item.Quantity, + productId: productId, + warehouseId: warehouseId, + subQty: item.Quantity, } aggregated = append(aggregated, entry) indexMap[key] = len(aggregated) - 1 } - prNumber := fmt.Sprintf("PR-%s-%s", time.Now().Format("20060102"), uuid.NewString()[:8]) - - var creditTerm *int - var dueDate *time.Time - - if supplier.DueDate > 0 { - ct := supplier.DueDate - creditTerm = &ct - d := time.Now().UTC().AddDate(0, 0, ct) - dueDate = &d - } + creditTermValue := req.CreditTerm + creditTerm := &creditTermValue + dueDateValue := time.Now().UTC().AddDate(0, 0, creditTermValue) + dueDate := &dueDateValue purchase := &entity.Purchase{ - PrNumber: prNumber, SupplierId: uint64(req.SupplierID), CreditTerm: creditTerm, DueDate: dueDate, @@ -182,20 +263,25 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase items := make([]*entity.PurchaseItem, 0, len(aggregated)) for _, item := range aggregated { items = append(items, &entity.PurchaseItem{ - ProductId: item.productId, - WarehouseId: item.warehouseId, - ProductWarehouseId: item.productWarehouseId, - SubQty: item.subQty, - TotalQty: item.subQty, - TotalUsed: 0, - Price: 0, - TotalPrice: 0, + ProductId: item.productId, + WarehouseId: item.warehouseId, + SubQty: item.subQty, + TotalQty: 0, + TotalUsed: 0, + Price: 0, + TotalPrice: 0, }) } - ctx := c.Context() transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + + code, err := purchaseRepoTx.NextPrNumber(ctx, tx) + if err != nil { + return err + } + purchase.PrNumber = code + if err := purchaseRepoTx.CreateWithItems(ctx, purchase, items); err != nil { return err } @@ -205,37 +291,184 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase actorID = 1 } action := entity.ApprovalActionCreated - - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) - if _, err := approvalSvc.CreateApproval( - ctx, - utils.ApprovalWorkflowPurchase, - uint(purchase.Id), - utils.PurchaseStepPengajuan, - &action, - actorID, - nil, - ); err != nil { + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepPengajuan, action, actorID, nil, false); err != nil { return err } return nil }) if transactionErr != nil { - s.Log.Errorf("Failed to create purchase requisition: %+v", transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase requisition") + s.Log.Errorf("Failed to create purchase: %+v", transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase") } created, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) if err != nil { - s.Log.Errorf("Failed to load created purchase requisition: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase requisition") + s.Log.Errorf("Failed to load created purchase: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } + if err := s.attachLatestApproval(ctx, created); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err) + } + + s.notifyExpenseItemsCreated(ctx, created.Id, created.Items) + return created, nil } func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { + return s.processStaffPurchaseApproval(c, id, req, false) +} + +func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest, requireStaffApproval bool) (*entity.Purchase, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID := uint(1) // TODO: replace with authenticated user id once available + + ctx := c.Context() + purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + } + + var latestStep uint16 + if purchase.LatestApproval != nil { + latestStep = purchase.LatestApproval.StepNumber + } + + if requireStaffApproval && latestStep < uint16(utils.PurchaseStepStaffPurchase) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot be edited before staff approval") + } + + isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase) + if isInitialApproval && latestStep != uint16(utils.PurchaseStepPengajuan) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase is not ready for staff approval") + } + + // Detect if purchase already has any receiving data on its items. + hasReceivingData := false + for _, item := range purchase.Items { + if item.TotalQty > 0 || item.TotalUsed > 0 { + hasReceivingData = true + break + } + } + + // After there is receiving data, staff edits are allowed to change quantity + // in sync with receiving, without creating new receiving approvals here. + syncReceiving := !isInitialApproval && hasReceivingData + + payload, err := s.buildStaffAdjustmentPayload(ctx, purchase, req, syncReceiving) + if err != nil { + return nil, err + } + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + grandTotalUpdated := false + if len(payload.PricingUpdates) > 0 { + if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil { + return err + } + grandTotalUpdated = true + } + + if len(payload.NewItems) > 0 { + if err := purchaseRepoTx.CreateItems(ctx, purchase.Id, payload.NewItems); err != nil { + return err + } + } + + if !grandTotalUpdated { + if err := purchaseRepoTx.UpdateGrandTotal(ctx, purchase.Id, payload.GrandTotal); err != nil { + return err + } + } + + if isInitialApproval { + action := entity.ApprovalActionApproved + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil { + return err + } + return nil + } + + if len(payload.PricingUpdates) > 0 || len(payload.NewItems) > 0 { + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + if approvalSvc != nil { + latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), nil) + if err != nil { + return err + } + + shouldRecordStaffUpdate := latest == nil || + latest.StepNumber != uint16(utils.PurchaseStepStaffPurchase) || + latest.Action == nil || + (latest.Action != nil && *latest.Action != entity.ApprovalActionUpdated) + + if shouldRecordStaffUpdate { + action := entity.ApprovalActionUpdated + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowPurchase, + uint(purchase.Id), + utils.PurchaseStepStaffPurchase, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + } + } + } + + return nil + }) + if transactionErr != nil { + if errors.Is(transactionErr, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") + } + if isInitialApproval { + s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update purchase pricing") + } + + updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + if err := s.attachLatestApproval(ctx, updated); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) + } + + 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(ctx, purchase.Id, newItems) + } + + return updated, nil +} + +func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -245,75 +478,381 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *val purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase requisition not found") + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") } - s.Log.Errorf("Failed to get purchase requisition: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase requisition") + s.Log.Errorf("Failed to get purchase: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - if len(purchase.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase requisition has no items to approve") + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } - requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items)) - for _, item := range req.Items { - requestItems[item.PurchaseItemID] = item - } - - updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) - var grandTotal float64 - - for _, item := range purchase.Items { - data, ok := requestItems[item.Id] - if !ok { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) - } - delete(requestItems, item.Id) - - if data.Price <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for item %d must be greater than 0", item.Id)) - } - - totalPrice := data.TotalPrice - if totalPrice == nil { - calculated := data.Price * item.TotalQty - totalPrice = &calculated - } - if *totalPrice <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for item %d must be greater than 0", item.Id)) - } - - updates = append(updates, rPurchase.PurchasePricingUpdate{ - ItemID: item.Id, - Price: data.Price, - TotalPrice: *totalPrice, - }) - grandTotal += *totalPrice - } - - if len(requestItems) > 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase requisition") + if purchase.LatestApproval == nil || + purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must reach staff purchase step before manager approval") } + actorID := uint(1) action := entity.ApprovalActionApproved - actorID := uint(1) // TODO: replace with authenticated user id once available + now := time.Now().UTC() + hasExistingPO := purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" + var generatedNumber string transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) - if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, updates, grandTotal); err != nil { + updateData := map[string]any{} + if !hasExistingPO { + repoTx := rPurchase.NewPurchaseRepository(tx) + code, err := repoTx.NextPoNumber(ctx, tx) + if err != nil { + return err + } + updateData["po_number"] = code + updateData["po_date"] = now + generatedNumber = code + } + + if len(updateData) > 0 { + repoTx := rPurchase.NewPurchaseRepository(tx) + if err := repoTx.PatchOne(ctx, uint(purchase.Id), updateData, nil); err != nil { + return err + } + } + + forceManagerApproval := false + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + if approvalSvcTx != nil { + filterByStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Where("step_number = ?", uint16(step)) + } + } + latestStaff, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase)) + if err != nil { + return err + } + latestManager, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager)) + if err != nil { + return err + } + if latestStaff != nil && latestManager != nil && latestStaff.ActionAt.After(latestManager.ActionAt) { + forceManagerApproval = true + } + } + + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil { return err } - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) - if _, err := approvalSvc.CreateApproval( - ctx, - utils.ApprovalWorkflowPurchase, - uint(purchase.Id), - utils.PurchaseStepStaffPurchase, - &action, - actorID, - req.Notes, - ); err != nil { + return nil + }) + if transactionErr != nil { + s.Log.Errorf("Failed to approve manager purchase %d: %+v", purchase.Id, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate purchase order") + } + + if generatedNumber != "" { + purchase.PoNumber = &generatedNumber + purchase.PoDate = &now + } + + updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + s.Log.Errorf("Failed to load purchase after manager approval: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + + if err := s.attachLatestApproval(ctx, updated); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) + } + + return updated, nil +} + +func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + ctx := c.Context() + + purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase ") + } + + if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase order has not been generated") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + } + + if purchase.LatestApproval == nil || + purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must be approved by manager before receiving products") + } + + itemMap := make(map[uint64]*entity.PurchaseItem, len(purchase.Items)) + for i := range purchase.Items { + itemMap[purchase.Items[i].Id] = &purchase.Items[i] + } + + type preparedReceiving struct { + item *entity.PurchaseItem + payload validation.ReceivePurchaseItemRequest + receivedDate time.Time + warehouseID uint + overrideWarehouse bool + receivedQty float64 + } + + prepared := make([]preparedReceiving, 0, len(req.Items)) + for _, payload := range req.Items { + item, exists := itemMap[payload.PurchaseItemID] + if !exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) + } + + receivedDate, err := time.Parse("2006-01-02", payload.ReceivedDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) + } + receivedDate = receivedDate.UTC() + + warehouseID := uint(item.WarehouseId) + overrideWarehouse := false + if payload.WarehouseID != nil && *payload.WarehouseID != 0 { + warehouseID = *payload.WarehouseID + overrideWarehouse = true + } + if warehouseID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) + } + + var receivedQty float64 + if payload.ReceivedQty != nil { + receivedQty = *payload.ReceivedQty + } else { + receivedQty = item.SubQty + } + if receivedQty < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) + } + if receivedQty > item.SubQty { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) + } + + prepared = append(prepared, preparedReceiving{ + item: item, + payload: payload, + receivedDate: receivedDate, + warehouseID: warehouseID, + overrideWarehouse: overrideWarehouse, + receivedQty: receivedQty, + }) + } + + receivingAction := entity.ApprovalActionApproved + completedAction := entity.ApprovalActionApproved + actorID := uint(1) + + approvalSvc := s.approvalServiceForDB(nil) + if approvalSvc != nil { + filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Where("step_number = ?", uint16(step)) + } + } + + latestReceiving, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterStep(utils.PurchaseStepReceiving)) + if err != nil { + s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + } + + if latestReceiving != nil { + receivingAction = entity.ApprovalActionUpdated + } + } + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := rPurchase.NewPurchaseRepository(tx) + pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) + + deltas := make(map[uint]float64) + affected := make(map[uint]struct{}) + updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) + + for _, prep := range prepared { + item := prep.item + + var oldPWID *uint + if item.ProductWarehouseId != nil { + idCopy := uint(*item.ProductWarehouseId) + oldPWID = &idCopy + } + + var newPWID *uint + clearPW := false + + if prep.receivedQty > 0 { + pwID, err := pwRepoTx.EnsureProductWarehouse(ctx, uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) + if err != nil { + return err + } + newPWID = &pwID + deltas[pwID] += prep.receivedQty + affected[pwID] = struct{}{} + } else { + clearPW = true + } + + if oldPWID != nil { + deltas[*oldPWID] -= item.TotalQty + affected[*oldPWID] = struct{}{} + } + + dateCopy := prep.receivedDate + qtyCopy := prep.receivedQty + update := rPurchase.PurchaseReceivingUpdate{ + ItemID: item.Id, + ReceivedDate: &dateCopy, + TravelNumber: prep.payload.TravelNumber, + TravelDocumentPath: prep.payload.TravelDocumentPath, + VehicleNumber: prep.payload.VehicleNumber, + ReceivedQty: &qtyCopy, + ProductWarehouseID: newPWID, + ClearProductWarehouse: clearPW, + } + + if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID { + warehouseCopy := prep.warehouseID + update.WarehouseID = &warehouseCopy + } + + updates = append(updates, update) + } + + if err := repoTx.UpdateReceivingDetails(ctx, purchase.Id, updates); err != nil { + return err + } + + if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil { + return err + } + + if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil { + return err + } + + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { + return err + } + + if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { + return err + } + + return nil + }) + if transactionErr != nil { + if errors.Is(transactionErr, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found for receiving") + } + s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + } + + updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase ") + } + if err := s.attachLatestApproval(ctx, updated); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) + } + + receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared)) + for _, prep := range prepared { + date := prep.receivedDate + payload := ExpenseReceivingPayload{ + PurchaseItemID: prep.item.Id, + ProductID: prep.item.ProductId, + WarehouseID: uint64(prep.warehouseID), + ReceivedQty: prep.receivedQty, + ReceivedDate: &date, + } + receivingPayloads = append(receivingPayloads, payload) + } + s.notifyExpenseItemsReceived(ctx, purchase.Id, receivingPayloads) + + return updated, nil +} + +func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + ctx := c.Context() + purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + } + if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot delete items before staff purchase approval") + } + + if len(purchase.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to delete") + } + + requested := make(map[uint64]struct{}, len(req.ItemIDs)) + for _, id := range req.ItemIDs { + requested[id] = struct{}{} + } + + toDelete := make([]uint64, 0, len(req.ItemIDs)) + var remainingTotal float64 + + for _, item := range purchase.Items { + if _, ok := requested[item.Id]; ok { + if item.TotalQty > 0 || item.TotalUsed > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete item %d because it already has receiving data", item.Id)) + } + toDelete = append(toDelete, item.Id) + } else { + remainingTotal += item.TotalPrice + } + } + + if len(toDelete) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Requested items were not found in this purchase") + } + + if len(purchase.Items)-len(toDelete) <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item") + } + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := rPurchase.NewPurchaseRepository(tx) + + if err := repoTx.DeleteItems(ctx, purchase.Id, toDelete); err != nil { + return err + } + + if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); err != nil { return err } @@ -323,15 +862,484 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *val if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") } - s.Log.Errorf("Failed to approve purchase requisition %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase requisition") + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items") + } + + if len(toDelete) > 0 { + s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete) } updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) if err != nil { - s.Log.Errorf("Failed to load purchase requisition after approval: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase requisition") + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + if err := s.attachLatestApproval(ctx, updated); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } return updated, nil } + +func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { + if id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + } + + ctx := c.Context() + purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Purchase not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + } + + itemIDs := make([]uint64, 0, len(purchase.Items)) + for _, item := range purchase.Items { + itemIDs = append(itemIDs, item.Id) + } + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowPurchase.String(), uint(id)); err != nil { + return err + } + + purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + if err := purchaseRepoTx.DeleteOne(ctx, uint(id)); err != nil { + return err + } + return nil + }) + if transactionErr != nil { + if errors.Is(transactionErr, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Purchase not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase") + } + + if len(itemIDs) > 0 { + s.notifyExpenseItemsDeleted(ctx, uint64(id), itemIDs) + } + + return nil +} + +func (s *purchaseService) createPurchaseApproval( + ctx context.Context, + db *gorm.DB, + purchaseID uint64, + 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 { + actorID = 1 + } + + svc := s.approvalServiceForDB(db) + + modifier := func(db *gorm.DB) *gorm.DB { + return db.Where("step_number = ?", uint16(step)) + } + + latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier) + if err != nil { + return err + } + + if !allowDuplicate && latest != nil && + latest.Action != nil && + *latest.Action == action { + return nil + } + + actionCopy := action + _, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes) + return err +} + +func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService { + if db != nil { + return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + } + if s.ApprovalSvc != nil { + return s.ApprovalSvc + } + return commonSvc.NewApprovalService(s.ApprovalRepo) +} + +func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error { + if len(items) == 0 || s.ApprovalSvc == nil { + return nil + } + + ids := make([]uint, 0, len(items)) + visited := make(map[uint64]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, uint(item.Id)) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + return err + } + + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[uint(items[i].Id)]; ok { + items[i].LatestApproval = approval + } else { + items[i].LatestApproval = nil + } + } + + return nil +} + +func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) { + if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { + return + } + if err := s.ExpenseBridge.OnItemsCreated(ctx, purchaseID, items); err != nil { + s.Log.Warnf("Failed to notify expense bridge for created purchase %d: %+v", purchaseID, err) + } +} + +func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint64, payloads []ExpenseReceivingPayload) { + if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { + return + } + 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) + } +} + +func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) { + if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 { + return + } + 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) + } +} + +func (s *purchaseService) buildStaffAdjustmentPayload( + ctx context.Context, + purchase *entity.Purchase, + req *validation.ApproveStaffPurchaseRequest, + syncReceiving bool, +) (*staffAdjustmentPayload, error) { + if len(req.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + } + + requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items)) + newPayloads := make([]validation.StaffPurchaseApprovalItem, 0) + + for _, item := range req.Items { + if item.PurchaseItemID == 0 { + newPayloads = append(newPayloads, item) + continue + } + if _, exists := requestItems[item.PurchaseItemID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) + } + requestItems[item.PurchaseItemID] = item + } + + updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) + var grandTotal float64 + + existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads)) + for _, item := range purchase.Items { + key := fmt.Sprintf("%d:%d", item.ProductId, item.WarehouseId) + existingCombos[key] = struct{}{} + } + + allowedWarehouses := make(map[uint64]struct{}, len(purchase.Items)) + for _, item := range purchase.Items { + allowedWarehouses[item.WarehouseId] = struct{}{} + } + if len(allowedWarehouses) == 0 && len(newPayloads) > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "No available warehouses for this purchase") + } + + productSupplierCache := make(map[uint64]bool) + + for _, item := range purchase.Items { + data, ok := requestItems[item.Id] + if !ok { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) + } + if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) + } + + effectiveProductID := item.ProductId + if data.ProductID != 0 && data.ProductID != item.ProductId { + if item.TotalQty > 0 || item.TotalUsed > 0 { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Cannot change product for item %d because it already has receiving data", item.Id), + ) + } + + if _, checked := productSupplierCache[data.ProductID]; !checked { + linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(data.ProductID), uint(purchase.SupplierId)) + if err != nil { + s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", data.ProductID, purchase.SupplierId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + } + if !linked { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product %d is not linked to supplier %d", data.ProductID, purchase.SupplierId), + ) + } + productSupplierCache[data.ProductID] = true + } + + oldKey := fmt.Sprintf("%d:%d", item.ProductId, item.WarehouseId) + newKey := fmt.Sprintf("%d:%d", data.ProductID, item.WarehouseId) + if _, exists := existingCombos[newKey]; exists && newKey != oldKey { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", data.ProductID, item.WarehouseId), + ) + } + if newKey != oldKey { + delete(existingCombos, oldKey) + existingCombos[newKey] = struct{}{} + } + + effectiveProductID = data.ProductID + } + + effectiveQty := item.SubQty + if data.Qty != nil { + if *data.Qty <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) + } + if item.TotalUsed > 0 && *data.Qty < item.TotalUsed { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) + } + if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id)) + } + effectiveQty = *data.Qty + } + + totalPriceInput := data.TotalPrice + totalPrice, err := calculateTotalPrice(effectiveQty, data.Price, &totalPriceInput, fmt.Sprintf("item %d", item.Id)) + if err != nil { + return nil, err + } + + update := rPurchase.PurchasePricingUpdate{ + ItemID: item.Id, + Price: data.Price, + TotalPrice: totalPrice, + } + if effectiveProductID != item.ProductId { + productIDCopy := effectiveProductID + update.ProductID = &productIDCopy + } + if data.Qty != nil { + qtyCopy := effectiveQty + update.Quantity = &qtyCopy + } + if syncReceiving { + qtyCopy := effectiveQty + update.TotalQty = &qtyCopy + } + + updates = append(updates, update) + grandTotal += totalPrice + delete(requestItems, item.Id) + } + if len(requestItems) > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase") + } + + newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) + + for _, payload := range newPayloads { + if payload.ProductID == 0 || payload.WarehouseID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Product and warehouse must be provided for new items") + } + if payload.Qty == nil || *payload.Qty <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) + } + if _, ok := allowedWarehouses[payload.WarehouseID]; !ok { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID), + ) + } + + key := fmt.Sprintf("%d:%d", payload.ProductID, payload.WarehouseID) + if _, exists := existingCombos[key]; exists { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID), + ) + } + + if _, checked := productSupplierCache[payload.ProductID]; !checked { + linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(payload.ProductID), uint(purchase.SupplierId)) + if err != nil { + s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", payload.ProductID, purchase.SupplierId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + } + if !linked { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId), + ) + } + productSupplierCache[payload.ProductID] = true + } + + qty := *payload.Qty + totalPriceInput := payload.TotalPrice + totalPrice, err := calculateTotalPrice(qty, payload.Price, &totalPriceInput, fmt.Sprintf("product %d in warehouse %d", payload.ProductID, payload.WarehouseID)) + if err != nil { + return nil, err + } + + newItem := &entity.PurchaseItem{ + PurchaseId: purchase.Id, + ProductId: payload.ProductID, + WarehouseId: payload.WarehouseID, + SubQty: qty, + TotalQty: 0, + TotalUsed: 0, + Price: payload.Price, + TotalPrice: totalPrice, + } + newItems = append(newItems, newItem) + existingCombos[key] = struct{}{} + grandTotal += totalPrice + } + + if len(updates) == 0 && len(newItems) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to process") + } + + return &staffAdjustmentPayload{ + PricingUpdates: updates, + NewItems: newItems, + GrandTotal: grandTotal, + }, nil +} + +func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) { + if quantity <= 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for %s must be greater than 0", ref)) + } + if price <= 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for %s must be greater than 0", ref)) + } + + fmt.Println(price, quantity) + expectedTotal := price * quantity + + if provided == nil { + return expectedTotal, nil + } + if *provided <= 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must be greater than 0", ref)) + } + if math.Abs(*provided-expectedTotal) > priceTolerance { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must equal quantity x price", ref)) + } + return *provided, nil +} + +func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error { + if item == nil || item.Id == 0 || s.ApprovalSvc == nil { + return nil + } + + latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(item.Id), func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + return err + } + + item.LatestApproval = latest + return nil +} + +func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) { + var fromPtr *time.Time + var toPtr *time.Time + + 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 parseApprovalAction(status string) (*entity.ApprovalAction, bool, error) { + value := strings.TrimSpace(strings.ToUpper(status)) + if value == "" { + return nil, false, nil + } + + if value == "COMPLETED" { + return nil, true, nil + } + + action := entity.ApprovalAction(value) + switch action { + case entity.ApprovalActionApproved, + entity.ApprovalActionRejected, + entity.ApprovalActionCreated, + entity.ApprovalActionUpdated: + return &action, false, nil + default: + return nil, false, fiber.NewError(fiber.StatusBadRequest, "Invalid status filter") + } +} diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 8791fc1c..3660263d 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -1,27 +1,62 @@ package validation type PurchaseItemPayload struct { - ProductID uint `json:"product_id" validate:"required"` - ProductWarehouseID *uint `json:"product_warehouse_id,omitempty" validate:"omitempty,gt=0"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` + WarehouseID uint `json:"warehouse_id" validate:"required,gt=0"` + ProductID uint `json:"product_id" validate:"required,gt=0"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` } type CreatePurchaseRequest struct { - SupplierID uint `json:"supplier_id" validate:"required"` - AreaID uint `json:"area_id" validate:"required"` - LocationID uint `json:"location_id" validate:"required"` - WarehouseID uint `json:"warehouse_id" validate:"required"` - Notes *string `json:"notes" validate:"omitempty,max=500"` - Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"` + SupplierID uint `json:"supplier_id" validate:"required,gt=0"` + CreditTerm int `json:"credit_term" validate:"required,gte=0"` + Notes *string `json:"notes" validate:"omitempty,max=500"` + Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"` } type StaffPurchaseApprovalItem struct { - PurchaseItemID uint64 `json:"purchase_item_id" validate:"required,gt=0"` + PurchaseItemID uint64 `json:"purchase_item_id,omitempty" validate:"omitempty,gt=0"` + ProductID uint64 `json:"product_id" validate:"required,gt=0"` + WarehouseID uint64 `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + Qty *float64 `json:"qty,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` Price float64 `json:"price" validate:"required,gt=0"` - TotalPrice *float64 `json:"total_price,omitempty" validate:"omitempty,gt=0"` + TotalPrice float64 `json:"total_price" validate:"required,gt=0"` } type ApproveStaffPurchaseRequest struct { Items []StaffPurchaseApprovalItem `json:"items" validate:"required,min=1,dive"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } + +type ApproveManagerPurchaseRequest struct { + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type ReceivePurchaseItemRequest struct { + PurchaseItemID uint64 `json:"purchase_item_id" validate:"required,gt=0"` + WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"` + ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"` + TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"` + TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"` + VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"` + ReceivedQty *float64 `json:"received_qty" validate:"omitempty,gte=0"` +} + +type ReceivePurchaseRequest struct { + Items []ReceivePurchaseItemRequest `json:"items" validate:"required,min=1,dive"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type DeletePurchaseItemsRequest struct { + ItemIDs []uint64 `json:"item_ids" validate:"required,min=1,dive,gt=0"` +} + +type PurchaseQuery struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + PrNumber string `query:"pr_number" validate:"omitempty,max=50"` + CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` + CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` + Status string `query:"status" validate:"omitempty,oneof=CREATED UPDATED APPROVED REJECTED COMPLETED"` +} diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 099b6510..dad324b9 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -217,11 +217,21 @@ const ( ApprovalWorkflowPurchase approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PURCHASES") PurchaseStepPengajuan approvalutils.ApprovalStep = 1 PurchaseStepStaffPurchase approvalutils.ApprovalStep = 2 + PurchaseStepManager approvalutils.ApprovalStep = 3 + PurchaseStepReceiving approvalutils.ApprovalStep = 4 + PurchaseStepCompleted approvalutils.ApprovalStep = 5 + + PurchasePRNumberPrefix = "PR-LTI-" + PurchasePONumberPrefix = "PO-LTI-" + PurchaseNumberPadding = 4 ) var PurchaseApprovalSteps = map[approvalutils.ApprovalStep]string{ PurchaseStepPengajuan: "Pengajuan", PurchaseStepStaffPurchase: "Staff Purchase", + PurchaseStepManager: "Manager Purchase", + PurchaseStepReceiving: "Penerimaan Produk", + PurchaseStepCompleted: "Selesai", } // ------------------------------------------------------------------- From 0708628b78e61fc82d304c57de81190c78e77d43 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 17 Nov 2025 11:53:27 +0700 Subject: [PATCH 2/3] feat(BE-229,234,235,230,231,232,233): purchase request and purchase order and fix master data dto --- .../purchases/services/purchase.service.go | 68 ++++++------------- .../validations/purchase.validation.go | 15 ++-- 2 files changed, 27 insertions(+), 56 deletions(-) diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 2082e195..7f694a12 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -355,7 +355,6 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase is not ready for staff approval") } - // Detect if purchase already has any receiving data on its items. hasReceivingData := false for _, item := range purchase.Items { if item.TotalQty > 0 || item.TotalUsed > 0 { @@ -364,8 +363,6 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, } } - // After there is receiving data, staff edits are allowed to change quantity - // in sync with receiving, without creating new receiving approvals here. syncReceiving := !isInitialApproval && hasReceivingData payload, err := s.buildStaffAdjustmentPayload(ctx, purchase, req, syncReceiving) @@ -611,6 +608,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati receivedQty float64 } + visitedItems := make(map[uint64]struct{}, len(req.Items)) prepared := make([]preparedReceiving, 0, len(req.Items)) for _, payload := range req.Items { item, exists := itemMap[payload.PurchaseItemID] @@ -647,6 +645,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } + if _, dup := visitedItems[payload.PurchaseItemID]; dup { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) + } + visitedItems[payload.PurchaseItemID] = struct{}{} + prepared = append(prepared, preparedReceiving{ item: item, payload: payload, @@ -657,6 +660,12 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati }) } + // Require receiving payload to cover all purchase items so that + // receiving cannot be submitted partially item-by-item. + if len(visitedItems) != len(itemMap) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must be provided for all purchase items") + } + receivingAction := entity.ApprovalActionApproved completedAction := entity.ApprovalActionApproved actorID := uint(1) @@ -1085,57 +1094,21 @@ func (s *purchaseService) buildStaffAdjustmentPayload( return nil, fiber.NewError(fiber.StatusBadRequest, "No available warehouses for this purchase") } - productSupplierCache := make(map[uint64]bool) - for _, item := range purchase.Items { data, ok := requestItems[item.Id] if !ok { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) } + if data.ProductID != 0 && data.ProductID != item.ProductId { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id), + ) + } if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) } - effectiveProductID := item.ProductId - if data.ProductID != 0 && data.ProductID != item.ProductId { - if item.TotalQty > 0 || item.TotalUsed > 0 { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Cannot change product for item %d because it already has receiving data", item.Id), - ) - } - - if _, checked := productSupplierCache[data.ProductID]; !checked { - linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(data.ProductID), uint(purchase.SupplierId)) - if err != nil { - s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", data.ProductID, purchase.SupplierId, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") - } - if !linked { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d is not linked to supplier %d", data.ProductID, purchase.SupplierId), - ) - } - productSupplierCache[data.ProductID] = true - } - - oldKey := fmt.Sprintf("%d:%d", item.ProductId, item.WarehouseId) - newKey := fmt.Sprintf("%d:%d", data.ProductID, item.WarehouseId) - if _, exists := existingCombos[newKey]; exists && newKey != oldKey { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", data.ProductID, item.WarehouseId), - ) - } - if newKey != oldKey { - delete(existingCombos, oldKey) - existingCombos[newKey] = struct{}{} - } - - effectiveProductID = data.ProductID - } - effectiveQty := item.SubQty if data.Qty != nil { if *data.Qty <= 0 { @@ -1161,10 +1134,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload( Price: data.Price, TotalPrice: totalPrice, } - if effectiveProductID != item.ProductId { - productIDCopy := effectiveProductID - update.ProductID = &productIDCopy - } if data.Qty != nil { qtyCopy := effectiveQty update.Quantity = &qtyCopy @@ -1182,6 +1151,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase") } + productSupplierCache := make(map[uint64]bool) newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) for _, payload := range newPayloads { diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 3660263d..4994a927 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -3,7 +3,7 @@ package validation type PurchaseItemPayload struct { WarehouseID uint `json:"warehouse_id" validate:"required,gt=0"` ProductID uint `json:"product_id" validate:"required,gt=0"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` + Quantity float64 `json:"qty" validate:"required,gt=0"` } type CreatePurchaseRequest struct { @@ -14,12 +14,13 @@ type CreatePurchaseRequest struct { } type StaffPurchaseApprovalItem struct { - PurchaseItemID uint64 `json:"purchase_item_id,omitempty" validate:"omitempty,gt=0"` - ProductID uint64 `json:"product_id" validate:"required,gt=0"` - WarehouseID uint64 `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` - Qty *float64 `json:"qty,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` - Price float64 `json:"price" validate:"required,gt=0"` - TotalPrice float64 `json:"total_price" validate:"required,gt=0"` + PurchaseItemID uint64 `json:"purchase_item_id,omitempty" validate:"omitempty,gt=0"` + // For new items (no purchase_item_id), product_id is required. + ProductID uint64 `json:"product_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + WarehouseID uint64 `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + Qty *float64 `json:"qty,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + Price float64 `json:"price" validate:"required,gt=0"` + TotalPrice float64 `json:"total_price" validate:"required,gt=0"` } type ApproveStaffPurchaseRequest struct { From 02cc082d672907228be81bd79cc521aaa92bfed0 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 17 Nov 2025 15:17:25 +0700 Subject: [PATCH 3/3] feat(BE-229,234,235,230,231,232,233): purchase request and purchase order and fix master data dto --- internal/modules/purchases/module.go | 7 ++++++- internal/modules/purchases/route.go | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index de79a0c9..1911e364 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -14,6 +14,8 @@ import ( rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -46,5 +48,8 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate expenseBridge, ) - Routes(router, purchaseService) + userRepo := rUser.NewUserRepository(db) + userService := sUser.NewUserService(userRepo, validate) + + Routes(router, purchaseService, userService) } diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go index 41706b01..aedc3ee8 100644 --- a/internal/modules/purchases/route.go +++ b/internal/modules/purchases/route.go @@ -2,14 +2,18 @@ package purchases import ( "github.com/gofiber/fiber/v2" + + middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/controllers" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" ) -func Routes(router fiber.Router, purchaseService service.PurchaseService) { +func Routes(router fiber.Router, purchaseService service.PurchaseService, userService user.UserService) { ctrl := controller.NewPurchaseController(purchaseService) route := router.Group("/purchases") + route.Use(middleware.Auth(userService)) route.Get("/", ctrl.GetAll) route.Get("/:id", ctrl.GetOne)