Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the order line could not represent: quantity is INTEGER and prices are always computed as quantity * unit_price. Model one weighing as one order line. quantity stays INTEGER and keeps meaning "how many items"; the measured amount goes into a new nullable order_items.weight, and the line is priced weight * unit_price. Two weighings of the same product are two lines, never merged into one. Keeping quantity integral avoids float comparisons in void, refund and split bill, where accumulated rounding error would silently misbehave — "1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid split-bill item marked unpaid. BillableQuantity() is now the single place that decides between weight and count; every price and cost calculation goes through it. Missing one would bill a 4.2 ons fish as a single ons — wrong money, no error. Two database constraints back the design: a weighed line always carries a positive weight, and its quantity is pinned to 1. The latter also makes void all-or-nothing for weighed lines, so the row-splitting branch can never produce a zero-weight remainder row. Also wires product.unit_id through the API, which was previously not settable at all, and corrects the misleading comment on the request's unit_price field — that value has never been used; price always comes from the database. Design notes and the audit of every price multiplication site are in docs/rfc-weight-based-products.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
298 lines
9.3 KiB
Go
298 lines
9.3 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"apskel-pos-be/internal/entities"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type OrderRepository interface {
|
|
Create(ctx context.Context, order *entities.Order) error
|
|
GetByID(ctx context.Context, id uuid.UUID) (*entities.Order, error)
|
|
GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Order, error)
|
|
Update(ctx context.Context, order *entities.Order) error
|
|
Delete(ctx context.Context, id uuid.UUID) error
|
|
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Order, int64, error)
|
|
ListBySessionID(ctx context.Context, sessionID string) ([]*entities.Order, error)
|
|
GetByOrderNumber(ctx context.Context, orderNumber string) (*entities.Order, error)
|
|
ExistsByOrderNumber(ctx context.Context, orderNumber string) (bool, error)
|
|
VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID) error
|
|
VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID) error
|
|
RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID) error
|
|
UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus) error
|
|
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus) error
|
|
GetNextOrderNumber(ctx context.Context, organizationID, outletID uuid.UUID) (string, error)
|
|
}
|
|
|
|
type OrderRepositoryImpl struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewOrderRepositoryImpl(db *gorm.DB) *OrderRepositoryImpl {
|
|
return &OrderRepositoryImpl{
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) Create(ctx context.Context, order *entities.Order) error {
|
|
return r.db.WithContext(ctx).Create(order).Error
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Order, error) {
|
|
var order entities.Order
|
|
err := r.db.WithContext(ctx).First(&order, "id = ?", id).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &order, nil
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Order, error) {
|
|
var order entities.Order
|
|
err := r.db.WithContext(ctx).
|
|
Preload("Organization").
|
|
Preload("Outlet").
|
|
Preload("User").
|
|
Preload("OrderItems").
|
|
Preload("OrderItems.Product").
|
|
Preload("OrderItems.Product.Category").
|
|
Preload("OrderItems.Product.ProductOutletPrices").
|
|
Preload("OrderItems.ProductVariant").
|
|
Preload("OrderItems.Unit").
|
|
Preload("Payments").
|
|
Preload("Payments.PaymentMethod").
|
|
Preload("Payments.PaymentOrderItems").
|
|
First(&order, "id = ?", id).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &order, nil
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) Update(ctx context.Context, order *entities.Order) error {
|
|
return r.db.WithContext(ctx).Save(order).Error
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) UpdateStatusSuccess(
|
|
ctx context.Context,
|
|
id uuid.UUID,
|
|
orderStatus entities.OrderStatus,
|
|
paymentStatus entities.PaymentStatus,
|
|
) error {
|
|
return r.db.WithContext(ctx).
|
|
Model(&entities.Order{}).
|
|
Where("id = ?", id.String()).
|
|
Updates(map[string]interface{}{
|
|
"status": orderStatus,
|
|
"payment_status": paymentStatus,
|
|
}).Error
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
|
return r.db.WithContext(ctx).Delete(&entities.Order{}, "id = ?", id).Error
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Order, int64, error) {
|
|
var orders []*entities.Order
|
|
var total int64
|
|
|
|
// organization_id is mandatory to prevent cross-org data leaks
|
|
organizationID, ok := filters["organization_id"]
|
|
if !ok {
|
|
return nil, 0, fmt.Errorf("organization_id is required for listing orders")
|
|
}
|
|
|
|
baseQuery := r.db.WithContext(ctx).Model(&entities.Order{}).
|
|
Where("organization_id = ?", organizationID)
|
|
|
|
// outlet_id is optional — if present, scope to that outlet; otherwise return all outlets in the org
|
|
if outletID, exists := filters["outlet_id"]; exists {
|
|
baseQuery = baseQuery.Where("outlet_id = ?", outletID)
|
|
}
|
|
|
|
for key, value := range filters {
|
|
switch key {
|
|
case "organization_id", "outlet_id":
|
|
// already handled above
|
|
case "search":
|
|
searchValue := "%" + value.(string) + "%"
|
|
baseQuery = baseQuery.Where("order_number ILIKE ?", searchValue)
|
|
case "date_from":
|
|
baseQuery = baseQuery.Where("created_at >= ?", value)
|
|
case "date_to":
|
|
baseQuery = baseQuery.Where("created_at <= ?", value)
|
|
default:
|
|
baseQuery = baseQuery.Where(key+" = ?", value)
|
|
}
|
|
}
|
|
|
|
// Use separate queries for count and find to avoid GORM state mutation issues
|
|
if err := baseQuery.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
err := baseQuery.
|
|
Preload("Organization").
|
|
Preload("Outlet").
|
|
Preload("User").
|
|
Preload("OrderItems").
|
|
Preload("OrderItems.Product").
|
|
Preload("OrderItems.Product.Category").
|
|
Preload("OrderItems.Product.ProductOutletPrices").
|
|
Preload("OrderItems.ProductVariant").
|
|
Preload("OrderItems.Unit").
|
|
Preload("Payments").
|
|
Preload("Payments.PaymentMethod").
|
|
Preload("Payments.PaymentOrderItems").
|
|
Limit(limit).Offset(offset).Order("created_at DESC").Find(&orders).Error
|
|
return orders, total, err
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID string) ([]*entities.Order, error) {
|
|
var orders []*entities.Order
|
|
err := r.db.WithContext(ctx).Model(&entities.Order{}).
|
|
Preload("Organization").
|
|
Preload("Outlet").
|
|
Preload("User").
|
|
Preload("OrderItems").
|
|
Preload("OrderItems.Product").
|
|
Preload("OrderItems.Product.Category").
|
|
Preload("OrderItems.Product.ProductOutletPrices").
|
|
Preload("OrderItems.ProductVariant").
|
|
Preload("OrderItems.Unit").
|
|
Preload("Payments").
|
|
Preload("Payments.PaymentMethod").
|
|
Preload("Payments.PaymentOrderItems").
|
|
Where("metadata->>'session_id' = ?", sessionID).
|
|
Order("created_at ASC").
|
|
Find(&orders).Error
|
|
return orders, err
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) GetByOrderNumber(ctx context.Context, orderNumber string) (*entities.Order, error) {
|
|
var order entities.Order
|
|
err := r.db.WithContext(ctx).First(&order, "order_number = ?", orderNumber).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &order, nil
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) ExistsByOrderNumber(ctx context.Context, orderNumber string) (bool, error) {
|
|
var count int64
|
|
err := r.db.WithContext(ctx).Model(&entities.Order{}).Where("order_number = ?", orderNumber).Count(&count).Error
|
|
return count > 0, err
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID) error {
|
|
now := time.Now()
|
|
return r.db.WithContext(ctx).Model(&entities.Order{}).
|
|
Where("id = ?", id).
|
|
Updates(map[string]interface{}{
|
|
"is_void": true,
|
|
"void_reason": reason,
|
|
"voided_at": now,
|
|
"voided_by": voidedBy,
|
|
}).Error
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID) error {
|
|
now := time.Now()
|
|
return r.db.WithContext(ctx).Model(&entities.Order{}).
|
|
Where("id = ?", id).
|
|
Updates(map[string]interface{}{
|
|
"status": status,
|
|
"is_void": true,
|
|
"void_reason": reason,
|
|
"voided_at": now,
|
|
"voided_by": voidedBy,
|
|
}).Error
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID) error {
|
|
now := time.Now()
|
|
return r.db.WithContext(ctx).Model(&entities.Order{}).
|
|
Where("id = ?", id).
|
|
Updates(map[string]interface{}{
|
|
"is_refund": true,
|
|
"refund_reason": reason,
|
|
"refunded_at": now,
|
|
"refunded_by": refundedBy,
|
|
}).Error
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus) error {
|
|
return r.db.WithContext(ctx).Model(&entities.Order{}).
|
|
Where("id = ?", id).
|
|
Update("payment_status", status).Error
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus) error {
|
|
return r.db.WithContext(ctx).Model(&entities.Order{}).
|
|
Where("id = ?", id).
|
|
Update("status", status).Error
|
|
}
|
|
|
|
func (r *OrderRepositoryImpl) GetNextOrderNumber(ctx context.Context, organizationID, outletID uuid.UUID) (string, error) {
|
|
now := time.Now()
|
|
year := now.Year()
|
|
month := int(now.Month())
|
|
|
|
// Use a transaction to ensure atomic sequence increment
|
|
tx := r.db.WithContext(ctx).Begin()
|
|
if tx.Error != nil {
|
|
return "", tx.Error
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
// Get or create sequence record
|
|
var sequence entities.OrderSequence
|
|
err := tx.Where("organization_id = ? AND outlet_id = ? AND year = ? AND month = ?",
|
|
organizationID, outletID, year, month).
|
|
First(&sequence).Error
|
|
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// Create new sequence record
|
|
sequence = entities.OrderSequence{
|
|
OrganizationID: organizationID,
|
|
OutletID: outletID,
|
|
Year: year,
|
|
Month: month,
|
|
SequenceNumber: 0,
|
|
}
|
|
if err := tx.Create(&sequence).Error; err != nil {
|
|
tx.Rollback()
|
|
return "", err
|
|
}
|
|
} else {
|
|
tx.Rollback()
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
// Increment sequence number
|
|
sequence.SequenceNumber++
|
|
if err := tx.Save(&sequence).Error; err != nil {
|
|
tx.Rollback()
|
|
return "", err
|
|
}
|
|
|
|
// Commit transaction
|
|
if err := tx.Commit().Error; err != nil {
|
|
return "", err
|
|
}
|
|
|
|
orderNumber := fmt.Sprintf("ORD/%04d%02d/%06d", year, month, sequence.SequenceNumber)
|
|
return orderNumber, nil
|
|
}
|