Update purchase for product category (inventory type)

This commit is contained in:
ryan 2026-06-09 15:59:34 +07:00
parent e7dd9660da
commit e09feff36d
15 changed files with 285 additions and 186 deletions

View File

@ -372,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo), purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo), unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo), chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),

View File

@ -19,11 +19,12 @@ type CreatePurchaseOrderRequest struct {
} }
type CreatePurchaseOrderItemRequest struct { type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` IngredientID uuid.UUID `json:"ingredient_id" validate:"required"`
Description *string `json:"description,omitempty" validate:"omitempty"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
Quantity float64 `json:"quantity" validate:"required,gt=0"` Description *string `json:"description,omitempty" validate:"omitempty"`
UnitID uuid.UUID `json:"unit_id" validate:"required"` Quantity float64 `json:"quantity" validate:"required,gt=0"`
Amount float64 `json:"amount" validate:"required,gte=0"` UnitID uuid.UUID `json:"unit_id" validate:"required"`
Amount float64 `json:"amount" validate:"required,gte=0"`
} }
type UpdatePurchaseOrderRequest struct { type UpdatePurchaseOrderRequest struct {
@ -39,12 +40,13 @@ type UpdatePurchaseOrderRequest struct {
} }
type UpdatePurchaseOrderItemRequest struct { type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
Description *string `json:"description,omitempty" validate:"omitempty"` PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` Description *string `json:"description,omitempty" validate:"omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"` UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
} }
type PurchaseOrderResponse struct { type PurchaseOrderResponse struct {
@ -66,17 +68,19 @@ type PurchaseOrderResponse struct {
} }
type PurchaseOrderItemResponse struct { type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Quantity float64 `json:"quantity"` Description *string `json:"description"`
UnitID uuid.UUID `json:"unit_id"` Quantity float64 `json:"quantity"`
Amount float64 `json:"amount"` UnitID uuid.UUID `json:"unit_id"`
CreatedAt time.Time `json:"created_at"` Amount float64 `json:"amount"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"` UpdatedAt time.Time `json:"updated_at"`
Unit *UnitResponse `json:"unit,omitempty"` Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
} }
type PurchaseOrderAttachmentResponse struct { type PurchaseOrderAttachmentResponse struct {

View File

@ -36,34 +36,36 @@ const (
) )
type InventoryMovement struct { type InventoryMovement struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"` OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"`
ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"` ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"`
ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT" ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT"
MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"` MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"`
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"` Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"` PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"` NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"` UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"`
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"` TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"` ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"` ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"` PurchaseOrderItemID *uuid.UUID `gorm:"type:uuid;index" json:"purchase_order_item_id"`
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"` OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"` PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
Reason *string `gorm:"size:255" json:"reason"` UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
Notes *string `gorm:"type:text" json:"notes"` Reason *string `gorm:"size:255" json:"reason"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` Notes *string `gorm:"type:text" json:"notes"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"` Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` PurchaseOrderItem *PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderItemID" json:"purchase_order_item,omitempty"`
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"` Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"` Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
} }
func (im *InventoryMovement) BeforeCreate(tx *gorm.DB) error { func (im *InventoryMovement) BeforeCreate(tx *gorm.DB) error {

View File

@ -41,19 +41,21 @@ func (PurchaseOrder) TableName() string {
} }
type PurchaseOrderItem struct { type PurchaseOrderItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"`
Description *string `gorm:"type:text" json:"description" validate:"omitempty"` PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"`
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
} }
func (poi *PurchaseOrderItem) BeforeCreate(tx *gorm.DB) error { func (poi *PurchaseOrderItem) BeforeCreate(tx *gorm.DB) error {

View File

@ -91,15 +91,16 @@ func PurchaseOrderItemEntityToModel(entity *entities.PurchaseOrderItem) *models.
} }
return &models.PurchaseOrderItem{ return &models.PurchaseOrderItem{
ID: entity.ID, ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID, PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID, IngredientID: entity.IngredientID,
Description: entity.Description, PurchaseCategoryID: entity.PurchaseCategoryID,
Quantity: entity.Quantity, Description: entity.Description,
UnitID: entity.UnitID, Quantity: entity.Quantity,
Amount: entity.Amount, UnitID: entity.UnitID,
CreatedAt: entity.CreatedAt, Amount: entity.Amount,
UpdatedAt: entity.UpdatedAt, CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
} }
} }
@ -109,15 +110,16 @@ func PurchaseOrderItemModelToEntity(model *models.PurchaseOrderItem) *entities.P
} }
return &entities.PurchaseOrderItem{ return &entities.PurchaseOrderItem{
ID: model.ID, ID: model.ID,
PurchaseOrderID: model.PurchaseOrderID, PurchaseOrderID: model.PurchaseOrderID,
IngredientID: model.IngredientID, IngredientID: model.IngredientID,
Description: model.Description, PurchaseCategoryID: model.PurchaseCategoryID,
Quantity: model.Quantity, Description: model.Description,
UnitID: model.UnitID, Quantity: model.Quantity,
Amount: model.Amount, UnitID: model.UnitID,
CreatedAt: model.CreatedAt, Amount: model.Amount,
UpdatedAt: model.UpdatedAt, CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
} }
} }
@ -127,15 +129,16 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
} }
response := &models.PurchaseOrderItemResponse{ response := &models.PurchaseOrderItemResponse{
ID: entity.ID, ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID, PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID, IngredientID: entity.IngredientID,
Description: entity.Description, PurchaseCategoryID: entity.PurchaseCategoryID,
Quantity: entity.Quantity, Description: entity.Description,
UnitID: entity.UnitID, Quantity: entity.Quantity,
Amount: entity.Amount, UnitID: entity.UnitID,
CreatedAt: entity.CreatedAt, Amount: entity.Amount,
UpdatedAt: entity.UpdatedAt, CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
} }
// Map ingredient if present // Map ingredient if present
@ -146,6 +149,10 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
} }
} }
if entity.PurchaseCategory != nil {
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
}
// Map unit if present // Map unit if present
if entity.Unit != nil { if entity.Unit != nil {
response.Unit = &models.UnitResponse{ response.Unit = &models.UnitResponse{

View File

@ -22,15 +22,16 @@ type PurchaseOrder struct {
} }
type PurchaseOrderItem struct { type PurchaseOrderItem struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Quantity float64 `json:"quantity"` Description *string `json:"description"`
UnitID uuid.UUID `json:"unit_id"` Quantity float64 `json:"quantity"`
Amount float64 `json:"amount"` UnitID uuid.UUID `json:"unit_id"`
CreatedAt time.Time `json:"created_at"` Amount float64 `json:"amount"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type PurchaseOrderAttachment struct { type PurchaseOrderAttachment struct {
@ -59,17 +60,19 @@ type PurchaseOrderResponse struct {
} }
type PurchaseOrderItemResponse struct { type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Quantity float64 `json:"quantity"` Description *string `json:"description"`
UnitID uuid.UUID `json:"unit_id"` Quantity float64 `json:"quantity"`
Amount float64 `json:"amount"` UnitID uuid.UUID `json:"unit_id"`
CreatedAt time.Time `json:"created_at"` Amount float64 `json:"amount"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"` UpdatedAt time.Time `json:"updated_at"`
Unit *UnitResponse `json:"unit,omitempty"` Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
} }
type PurchaseOrderAttachmentResponse struct { type PurchaseOrderAttachmentResponse struct {
@ -93,11 +96,12 @@ type CreatePurchaseOrderRequest struct {
} }
type CreatePurchaseOrderItemRequest struct { type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description,omitempty"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Quantity float64 `json:"quantity"` Description *string `json:"description,omitempty"`
UnitID uuid.UUID `json:"unit_id"` Quantity float64 `json:"quantity"`
Amount float64 `json:"amount"` UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"`
} }
type UpdatePurchaseOrderRequest struct { type UpdatePurchaseOrderRequest struct {
@ -113,12 +117,13 @@ type UpdatePurchaseOrderRequest struct {
} }
type UpdatePurchaseOrderItemRequest struct { type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
Description *string `json:"description,omitempty"` PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
Quantity *float64 `json:"quantity,omitempty"` Description *string `json:"description,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"` Quantity *float64 `json:"quantity,omitempty"`
Amount *float64 `json:"amount,omitempty"` UnitID *uuid.UUID `json:"unit_id,omitempty"`
Amount *float64 `json:"amount,omitempty"`
} }
type ListPurchaseOrdersRequest struct { type ListPurchaseOrdersRequest struct {

View File

@ -86,7 +86,7 @@ type CustomerRepository interface {
} }
type InventoryMovementService interface { type InventoryMovementService interface {
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
} }

View File

@ -25,6 +25,7 @@ type PurchaseOrderProcessorImpl struct {
purchaseOrderRepo PurchaseOrderRepository purchaseOrderRepo PurchaseOrderRepository
vendorRepo VendorRepository vendorRepo VendorRepository
ingredientRepo IngredientRepository ingredientRepo IngredientRepository
purchaseCategoryRepo PurchaseCategoryRepository
unitRepo UnitRepository unitRepo UnitRepository
fileRepo FileRepository fileRepo FileRepository
inventoryMovementService InventoryMovementService inventoryMovementService InventoryMovementService
@ -35,6 +36,7 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo PurchaseOrderRepository, purchaseOrderRepo PurchaseOrderRepository,
vendorRepo VendorRepository, vendorRepo VendorRepository,
ingredientRepo IngredientRepository, ingredientRepo IngredientRepository,
purchaseCategoryRepo PurchaseCategoryRepository,
unitRepo UnitRepository, unitRepo UnitRepository,
fileRepo FileRepository, fileRepo FileRepository,
inventoryMovementService InventoryMovementService, inventoryMovementService InventoryMovementService,
@ -44,6 +46,7 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo: purchaseOrderRepo, purchaseOrderRepo: purchaseOrderRepo,
vendorRepo: vendorRepo, vendorRepo: vendorRepo,
ingredientRepo: ingredientRepo, ingredientRepo: ingredientRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
unitRepo: unitRepo, unitRepo: unitRepo,
fileRepo: fileRepo, fileRepo: fileRepo,
inventoryMovementService: inventoryMovementService, inventoryMovementService: inventoryMovementService,
@ -64,13 +67,17 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber) return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
} }
// Validate ingredients and units exist // Validate ingredients, raw-material categories, and units exist
for i, item := range req.Items { for i, item := range req.Items {
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) _, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
} }
if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil {
return nil, err
}
_, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID) _, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("unit not found for item %d: %w", i, err) return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
@ -109,12 +116,13 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
// Create purchase order items // Create purchase order items
for _, itemReq := range req.Items { for _, itemReq := range req.Items {
itemEntity := &entities.PurchaseOrderItem{ itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID, PurchaseOrderID: poEntity.ID,
IngredientID: itemReq.IngredientID, IngredientID: itemReq.IngredientID,
Description: itemReq.Description, PurchaseCategoryID: itemReq.PurchaseCategoryID,
Quantity: itemReq.Quantity, Description: itemReq.Description,
UnitID: itemReq.UnitID, Quantity: itemReq.Quantity,
Amount: itemReq.Amount, UnitID: itemReq.UnitID,
Amount: itemReq.Amount,
} }
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
@ -197,7 +205,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
// Create new items // Create new items
totalAmount := 0.0 totalAmount := 0.0
for _, itemReq := range req.Items { for i, itemReq := range req.Items {
// Validate ingredients and units exist // Validate ingredients and units exist
if itemReq.IngredientID != nil { if itemReq.IngredientID != nil {
_, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID) _, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID)
@ -213,8 +221,15 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
} }
} }
if itemReq.PurchaseCategoryID != nil {
if err := p.validateRawMaterialPurchaseCategory(ctx, *itemReq.PurchaseCategoryID, organizationID, i); err != nil {
return nil, err
}
}
// Use existing values if not provided // Use existing values if not provided
ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach
purchaseCategoryID := poEntity.Items[0].PurchaseCategoryID
unitID := poEntity.Items[0].UnitID unitID := poEntity.Items[0].UnitID
quantity := poEntity.Items[0].Quantity quantity := poEntity.Items[0].Quantity
amount := poEntity.Items[0].Amount amount := poEntity.Items[0].Amount
@ -226,6 +241,9 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
if itemReq.UnitID != nil { if itemReq.UnitID != nil {
unitID = *itemReq.UnitID unitID = *itemReq.UnitID
} }
if itemReq.PurchaseCategoryID != nil {
purchaseCategoryID = *itemReq.PurchaseCategoryID
}
if itemReq.Quantity != nil { if itemReq.Quantity != nil {
quantity = *itemReq.Quantity quantity = *itemReq.Quantity
} }
@ -237,12 +255,13 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
} }
itemEntity := &entities.PurchaseOrderItem{ itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID, PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID, IngredientID: ingredientID,
Description: description, PurchaseCategoryID: purchaseCategoryID,
Quantity: quantity, Description: description,
UnitID: unitID, Quantity: quantity,
Amount: amount, UnitID: unitID,
Amount: amount,
} }
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
@ -419,6 +438,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
reason, reason,
&referenceType, &referenceType,
referenceID, referenceID,
&item.ID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err) return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err)
@ -440,3 +460,20 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
} }
func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil {
return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
}
if !category.IsActive {
return fmt.Errorf("purchase category for item %d is inactive", itemIndex)
}
if category.Type != entities.PurchaseCategoryTypeRawMaterial {
return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex)
}
return nil
}

View File

@ -31,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID)
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Preload("Attachments.File"). Preload("Attachments.File").
First(&po, "id = ?", id).Error First(&po, "id = ?", id).Error
@ -45,6 +46,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Preload("Attachments.File"). Preload("Attachments.File").
Where("id = ? AND organization_id = ?", id, organizationID). Where("id = ? AND organization_id = ?", id, organizationID).
@ -105,6 +107,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
err := query. err := query.
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Preload("Attachments.File"). Preload("Attachments.File").
Order("created_at DESC"). Order("created_at DESC").
@ -168,6 +171,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat
Where("organization_id = ? AND status = ?", organizationID, status). Where("organization_id = ? AND status = ?", organizationID, status).
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Find(&pos).Error Find(&pos).Error
return pos, err return pos, err
@ -179,6 +183,7 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}). Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Find(&pos).Error Find(&pos).Error
return pos, err return pos, err
@ -219,6 +224,7 @@ func (r *PurchaseOrderRepositoryImpl) GetItemsByPurchaseOrderID(ctx context.Cont
var items []*entities.PurchaseOrderItem var items []*entities.PurchaseOrderItem
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Preload("Ingredient"). Preload("Ingredient").
Preload("PurchaseCategory").
Preload("Unit"). Preload("Unit").
Where("purchase_order_id = ?", purchaseOrderID). Where("purchase_order_id = ?", purchaseOrderID).
Find(&items).Error Find(&items).Error

View File

@ -10,7 +10,7 @@ import (
) )
type InventoryMovementService interface { type InventoryMovementService interface {
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
} }
@ -26,7 +26,7 @@ func NewInventoryMovementService(inventoryMovementRepo repository.InventoryMovem
} }
} }
func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error { func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error {
ingredient, err := s.ingredientRepo.GetByID(ctx, ingredientID, organizationID) ingredient, err := s.ingredientRepo.GetByID(ctx, ingredientID, organizationID)
if err != nil { if err != nil {
return err return err
@ -36,22 +36,23 @@ func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Cont
newQuantity := previousQuantity + quantity newQuantity := previousQuantity + quantity
movement := &entities.InventoryMovement{ movement := &entities.InventoryMovement{
ID: uuid.New(), ID: uuid.New(),
OrganizationID: organizationID, OrganizationID: organizationID,
OutletID: outletID, OutletID: outletID,
ItemID: ingredientID, ItemID: ingredientID,
ItemType: "INGREDIENT", ItemType: "INGREDIENT",
MovementType: movementType, MovementType: movementType,
Quantity: quantity, Quantity: quantity,
PreviousQuantity: previousQuantity, PreviousQuantity: previousQuantity,
NewQuantity: newQuantity, NewQuantity: newQuantity,
UnitCost: unitCost, UnitCost: unitCost,
TotalCost: unitCost * quantity, TotalCost: unitCost * quantity,
ReferenceType: referenceType, ReferenceType: referenceType,
ReferenceID: referenceID, ReferenceID: referenceID,
UserID: userID, PurchaseOrderItemID: purchaseOrderItemID,
Reason: &reason, UserID: userID,
CreatedAt: time.Now(), Reason: &reason,
CreatedAt: time.Now(),
} }
err = s.inventoryMovementRepo.Create(ctx, movement) err = s.inventoryMovementRepo.Create(ctx, movement)

View File

@ -11,11 +11,12 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
items := make([]models.CreatePurchaseOrderItemRequest, len(req.Items)) items := make([]models.CreatePurchaseOrderItemRequest, len(req.Items))
for i, item := range req.Items { for i, item := range req.Items {
items[i] = models.CreatePurchaseOrderItemRequest{ items[i] = models.CreatePurchaseOrderItemRequest{
IngredientID: item.IngredientID, IngredientID: item.IngredientID,
Description: item.Description, PurchaseCategoryID: item.PurchaseCategoryID,
Quantity: item.Quantity, Description: item.Description,
UnitID: item.UnitID, Quantity: item.Quantity,
Amount: item.Amount, UnitID: item.UnitID,
Amount: item.Amount,
} }
} }
@ -54,12 +55,13 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
items = make([]models.UpdatePurchaseOrderItemRequest, len(req.Items)) items = make([]models.UpdatePurchaseOrderItemRequest, len(req.Items))
for i, item := range req.Items { for i, item := range req.Items {
items[i] = models.UpdatePurchaseOrderItemRequest{ items[i] = models.UpdatePurchaseOrderItemRequest{
ID: item.ID, ID: item.ID,
IngredientID: item.IngredientID, IngredientID: item.IngredientID,
Description: item.Description, PurchaseCategoryID: item.PurchaseCategoryID,
Quantity: item.Quantity, Description: item.Description,
UnitID: item.UnitID, Quantity: item.Quantity,
Amount: item.Amount, UnitID: item.UnitID,
Amount: item.Amount,
} }
} }
} }
@ -154,15 +156,16 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
response.Items = make([]contract.PurchaseOrderItemResponse, len(po.Items)) response.Items = make([]contract.PurchaseOrderItemResponse, len(po.Items))
for i, item := range po.Items { for i, item := range po.Items {
response.Items[i] = contract.PurchaseOrderItemResponse{ response.Items[i] = contract.PurchaseOrderItemResponse{
ID: item.ID, ID: item.ID,
PurchaseOrderID: item.PurchaseOrderID, PurchaseOrderID: item.PurchaseOrderID,
IngredientID: item.IngredientID, IngredientID: item.IngredientID,
Description: item.Description, PurchaseCategoryID: item.PurchaseCategoryID,
Quantity: item.Quantity, Description: item.Description,
UnitID: item.UnitID, Quantity: item.Quantity,
Amount: item.Amount, UnitID: item.UnitID,
CreatedAt: item.CreatedAt, Amount: item.Amount,
UpdatedAt: item.UpdatedAt, CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
} }
// Map ingredient if present // Map ingredient if present
@ -173,6 +176,10 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
} }
} }
if item.PurchaseCategory != nil {
response.Items[i].PurchaseCategory = PurchaseCategoryModelResponseToResponse(item.PurchaseCategory)
}
// Map unit if present // Map unit if present
if item.Unit != nil { if item.Unit != nil {
response.Items[i].Unit = &contract.UnitResponse{ response.Items[i].Unit = &contract.UnitResponse{

View File

@ -2,11 +2,14 @@ package validator
import ( import (
"errors" "errors"
"strconv"
"strings" "strings"
"time" "time"
"apskel-pos-be/internal/constants" "apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract" "apskel-pos-be/internal/contract"
"github.com/google/uuid"
) )
type PurchaseOrderValidator interface { type PurchaseOrderValidator interface {
@ -26,7 +29,7 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
return errors.New("request body is required"), constants.MissingFieldErrorCode return errors.New("request body is required"), constants.MissingFieldErrorCode
} }
if req.VendorID.String() == "" { if req.VendorID == uuid.Nil {
return errors.New("vendor_id is required"), constants.MissingFieldErrorCode return errors.New("vendor_id is required"), constants.MissingFieldErrorCode
} }
@ -178,32 +181,40 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
} }
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
if item.IngredientID.String() == "" { if item.IngredientID == uuid.Nil {
return errors.New("items[" + string(rune(index)) + "].ingredient_id is required"), constants.MissingFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode
}
if item.PurchaseCategoryID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
} }
if item.Quantity <= 0 { if item.Quantity <= 0 {
return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
} }
if item.UnitID.String() == "" { if item.UnitID == uuid.Nil {
return errors.New("items[" + string(rune(index)) + "].unit_id is required"), constants.MissingFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode
} }
if item.Amount < 0 { if item.Amount < 0 {
return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
} }
return nil, "" return nil, ""
} }
func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contract.UpdatePurchaseOrderItemRequest, index int) (error, string) { func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contract.UpdatePurchaseOrderItemRequest, index int) (error, string) {
if item.PurchaseCategoryID == nil || *item.PurchaseCategoryID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
}
if item.Quantity != nil && *item.Quantity <= 0 { if item.Quantity != nil && *item.Quantity <= 0 {
return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
} }
if item.Amount != nil && *item.Amount < 0 { if item.Amount != nil && *item.Amount < 0 {
return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
} }
return nil, "" return nil, ""

View File

@ -17,10 +17,11 @@ func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
TransactionDate: "2026-05-29", TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{ Items: []contract.CreatePurchaseOrderItemRequest{
{ {
IngredientID: uuid.New(), IngredientID: uuid.New(),
Quantity: 1, PurchaseCategoryID: uuid.New(),
UnitID: uuid.New(), Quantity: 1,
Amount: 1000, UnitID: uuid.New(),
Amount: 1000,
}, },
}, },
} }

View File

@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_inventory_movements_purchase_order_item_id;
ALTER TABLE inventory_movements DROP COLUMN IF EXISTS purchase_order_item_id;
DROP INDEX IF EXISTS idx_purchase_order_items_purchase_category_id;
ALTER TABLE purchase_order_items DROP COLUMN IF EXISTS purchase_category_id;

View File

@ -0,0 +1,11 @@
ALTER TABLE purchase_order_items
ADD COLUMN IF NOT EXISTS purchase_category_id UUID REFERENCES purchase_categories(id) ON DELETE RESTRICT;
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_purchase_category_id
ON purchase_order_items(purchase_category_id);
ALTER TABLE inventory_movements
ADD COLUMN IF NOT EXISTS purchase_order_item_id UUID REFERENCES purchase_order_items(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_inventory_movements_purchase_order_item_id
ON inventory_movements(purchase_order_item_id);