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>
1066 lines
35 KiB
Go
1066 lines
35 KiB
Go
package transformer
|
|
|
|
import (
|
|
"apskel-pos-be/internal/contract"
|
|
"apskel-pos-be/internal/models"
|
|
"apskel-pos-be/internal/util"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// parseOutletID converts a *string outlet ID to *uuid.UUID, returning nil for invalid/empty values.
|
|
func parseOutletID(s *string) *uuid.UUID {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
id, err := uuid.Parse(*s)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &id
|
|
}
|
|
|
|
// PaymentMethodAnalyticsContractToModel converts contract request to model
|
|
func PaymentMethodAnalyticsContractToModel(req *contract.PaymentMethodAnalyticsRequest) *models.PaymentMethodAnalyticsRequest {
|
|
var dateFrom, dateTo time.Time
|
|
|
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
|
if fromTime != nil {
|
|
dateFrom = *fromTime
|
|
}
|
|
if toTime != nil {
|
|
dateTo = *toTime
|
|
}
|
|
}
|
|
|
|
return &models.PaymentMethodAnalyticsRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateFrom: dateFrom,
|
|
DateTo: dateTo,
|
|
GroupBy: req.GroupBy,
|
|
}
|
|
}
|
|
|
|
// PaymentMethodAnalyticsModelToContract converts model response to contract
|
|
func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsResponse) *contract.PaymentMethodAnalyticsResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
var data []contract.PaymentMethodAnalyticsData
|
|
for _, item := range resp.Data {
|
|
data = append(data, contract.PaymentMethodAnalyticsData{
|
|
PaymentMethodID: item.PaymentMethodID,
|
|
PaymentMethodName: item.PaymentMethodName,
|
|
PaymentMethodType: item.PaymentMethodType,
|
|
TotalAmount: item.TotalAmount,
|
|
OrderCount: item.OrderCount,
|
|
PaymentCount: item.PaymentCount,
|
|
Percentage: item.Percentage,
|
|
})
|
|
}
|
|
|
|
return &contract.PaymentMethodAnalyticsResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
DateFrom: resp.DateFrom,
|
|
DateTo: resp.DateTo,
|
|
GroupBy: resp.GroupBy,
|
|
Summary: contract.PaymentMethodSummary{
|
|
TotalAmount: resp.Summary.TotalAmount,
|
|
TotalOrders: resp.Summary.TotalOrders,
|
|
TotalPayments: resp.Summary.TotalPayments,
|
|
AverageOrderValue: resp.Summary.AverageOrderValue,
|
|
},
|
|
Data: data,
|
|
}
|
|
}
|
|
|
|
func SalesAnalyticsContractToModel(req *contract.SalesAnalyticsRequest) *models.SalesAnalyticsRequest {
|
|
var dateFrom, dateTo time.Time
|
|
|
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
|
if fromTime != nil {
|
|
dateFrom = *fromTime
|
|
}
|
|
if toTime != nil {
|
|
dateTo = *toTime
|
|
}
|
|
}
|
|
|
|
return &models.SalesAnalyticsRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateFrom: dateFrom,
|
|
DateTo: dateTo,
|
|
GroupBy: req.GroupBy,
|
|
}
|
|
}
|
|
|
|
// SalesAnalyticsModelToContract converts model response to contract
|
|
func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contract.SalesAnalyticsResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
var data []contract.SalesAnalyticsData
|
|
for _, item := range resp.Data {
|
|
data = append(data, contract.SalesAnalyticsData{
|
|
Date: item.Date,
|
|
Sales: item.Sales,
|
|
Orders: item.Orders,
|
|
Items: item.Items,
|
|
Tax: item.Tax,
|
|
Discount: item.Discount,
|
|
NetSales: item.NetSales,
|
|
})
|
|
}
|
|
|
|
return &contract.SalesAnalyticsResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
DateFrom: resp.DateFrom,
|
|
DateTo: resp.DateTo,
|
|
GroupBy: resp.GroupBy,
|
|
Summary: contract.SalesSummary{
|
|
TotalSales: resp.Summary.TotalSales,
|
|
TotalOrders: resp.Summary.TotalOrders,
|
|
TotalItems: resp.Summary.TotalItems,
|
|
AverageOrderValue: resp.Summary.AverageOrderValue,
|
|
TotalTax: resp.Summary.TotalTax,
|
|
TotalDiscount: resp.Summary.TotalDiscount,
|
|
NetSales: resp.Summary.NetSales,
|
|
},
|
|
Data: data,
|
|
}
|
|
}
|
|
|
|
// PurchasingAnalyticsContractToModel converts contract request to model
|
|
func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest) *models.PurchasingAnalyticsRequest {
|
|
var dateFrom, dateTo time.Time
|
|
|
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
|
if fromTime != nil {
|
|
dateFrom = *fromTime
|
|
}
|
|
if toTime != nil {
|
|
dateTo = *toTime
|
|
}
|
|
}
|
|
|
|
return &models.PurchasingAnalyticsRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
Team: req.Team,
|
|
DateFrom: dateFrom,
|
|
DateTo: dateTo,
|
|
GroupBy: req.GroupBy,
|
|
}
|
|
}
|
|
|
|
// PurchasingAnalyticsModelToContract converts model response to contract
|
|
func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse) *contract.PurchasingAnalyticsResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
data := make([]contract.PurchasingAnalyticsData, len(resp.Data))
|
|
for i, item := range resp.Data {
|
|
data[i] = contract.PurchasingAnalyticsData{
|
|
Date: item.Date,
|
|
Purchases: item.Purchases,
|
|
RawMaterialPurchases: item.RawMaterialPurchases,
|
|
ExpensePurchases: item.ExpensePurchases,
|
|
PurchaseOrders: item.PurchaseOrders,
|
|
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
|
|
ExpenseCount: item.ExpenseCount,
|
|
Quantity: item.Quantity,
|
|
Ingredients: item.Ingredients,
|
|
Vendors: item.Vendors,
|
|
}
|
|
}
|
|
|
|
ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData))
|
|
for i, item := range resp.IngredientData {
|
|
ingredientData[i] = contract.PurchasingIngredientData{
|
|
IngredientID: item.IngredientID,
|
|
IngredientName: item.IngredientName,
|
|
Quantity: item.Quantity,
|
|
TotalCost: item.TotalCost,
|
|
AverageUnitCost: item.AverageUnitCost,
|
|
PurchaseOrderCount: item.PurchaseOrderCount,
|
|
}
|
|
}
|
|
|
|
vendorData := make([]contract.PurchasingVendorData, len(resp.VendorData))
|
|
for i, item := range resp.VendorData {
|
|
vendorData[i] = contract.PurchasingVendorData{
|
|
VendorID: item.VendorID,
|
|
VendorName: item.VendorName,
|
|
TotalCost: item.TotalCost,
|
|
PurchaseOrderCount: item.PurchaseOrderCount,
|
|
IngredientCount: item.IngredientCount,
|
|
Quantity: item.Quantity,
|
|
}
|
|
}
|
|
|
|
teamData := make([]contract.PurchasingTeamData, len(resp.TeamData))
|
|
for i, item := range resp.TeamData {
|
|
teamData[i] = contract.PurchasingTeamData{
|
|
Scope: item.Scope,
|
|
CategoryID: item.CategoryID,
|
|
Name: item.Name,
|
|
TotalPurchases: item.TotalPurchases,
|
|
RawMaterialPurchases: item.RawMaterialPurchases,
|
|
ExpensePurchases: item.ExpensePurchases,
|
|
PurchaseOrderCount: item.PurchaseOrderCount,
|
|
Quantity: item.Quantity,
|
|
Percentage: item.Percentage,
|
|
}
|
|
}
|
|
|
|
return &contract.PurchasingAnalyticsResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
Team: resp.Team,
|
|
DateFrom: resp.DateFrom,
|
|
DateTo: resp.DateTo,
|
|
GroupBy: resp.GroupBy,
|
|
Summary: contract.PurchasingSummary{
|
|
TotalPurchases: resp.Summary.TotalPurchases,
|
|
RawMaterialPurchases: resp.Summary.RawMaterialPurchases,
|
|
ExpensePurchases: resp.Summary.ExpensePurchases,
|
|
TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders,
|
|
RawMaterialPurchaseOrders: resp.Summary.RawMaterialPurchaseOrders,
|
|
ExpenseCount: resp.Summary.ExpenseCount,
|
|
TotalQuantity: resp.Summary.TotalQuantity,
|
|
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
|
|
TotalIngredients: resp.Summary.TotalIngredients,
|
|
TotalVendors: resp.Summary.TotalVendors,
|
|
TotalTeams: resp.Summary.TotalTeams,
|
|
},
|
|
Data: data,
|
|
IngredientData: ingredientData,
|
|
VendorData: vendorData,
|
|
TeamData: teamData,
|
|
}
|
|
}
|
|
|
|
// ProductAnalyticsContractToModel converts contract request to model
|
|
func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *models.ProductAnalyticsRequest {
|
|
var dateFrom, dateTo time.Time
|
|
|
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
|
if fromTime != nil {
|
|
dateFrom = *fromTime
|
|
}
|
|
if toTime != nil {
|
|
dateTo = *toTime
|
|
}
|
|
}
|
|
|
|
return &models.ProductAnalyticsRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateFrom: dateFrom,
|
|
DateTo: dateTo,
|
|
Limit: req.Limit,
|
|
}
|
|
}
|
|
|
|
// ProductAnalyticsModelToContract converts model response to contract
|
|
func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *contract.ProductAnalyticsResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
var data []contract.ProductAnalyticsData
|
|
for _, item := range resp.Data {
|
|
data = append(data, contract.ProductAnalyticsData{
|
|
ProductID: item.ProductID,
|
|
ProductName: item.ProductName,
|
|
ProductSku: item.ProductSku,
|
|
ProductPrice: item.ProductPrice,
|
|
CategoryID: item.CategoryID,
|
|
CategoryName: item.CategoryName,
|
|
CategoryOrder: item.CategoryOrder,
|
|
QuantitySold: item.QuantitySold,
|
|
WeightSold: item.WeightSold,
|
|
Revenue: item.Revenue,
|
|
AveragePrice: item.AveragePrice,
|
|
OrderCount: item.OrderCount,
|
|
StandardHppPerUnit: item.StandardHppPerUnit,
|
|
StandardHppTotal: item.StandardHppTotal,
|
|
FifoHppPerUnit: item.FifoHppPerUnit,
|
|
FifoHppTotal: item.FifoHppTotal,
|
|
MovingAverageHppPerUnit: item.MovingAverageHppPerUnit,
|
|
MovingAverageHppTotal: item.MovingAverageHppTotal,
|
|
})
|
|
}
|
|
|
|
return &contract.ProductAnalyticsResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
DateFrom: resp.DateFrom,
|
|
DateTo: resp.DateTo,
|
|
Data: data,
|
|
}
|
|
}
|
|
|
|
// ProductAnalyticsPerCategoryContractToModel converts contract request to model
|
|
func ProductAnalyticsPerCategoryContractToModel(req *contract.ProductAnalyticsPerCategoryRequest) *models.ProductAnalyticsPerCategoryRequest {
|
|
var dateFrom, dateTo time.Time
|
|
|
|
// Parse date range using utility function
|
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
|
if fromTime != nil {
|
|
dateFrom = *fromTime
|
|
}
|
|
if toTime != nil {
|
|
dateTo = *toTime
|
|
}
|
|
}
|
|
|
|
return &models.ProductAnalyticsPerCategoryRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateFrom: dateFrom,
|
|
DateTo: dateTo,
|
|
}
|
|
}
|
|
|
|
// ProductAnalyticsPerCategoryModelToContract converts model response to contract
|
|
func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPerCategoryResponse) *contract.ProductAnalyticsPerCategoryResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
var data []contract.ProductAnalyticsPerCategoryData
|
|
for _, item := range resp.Data {
|
|
data = append(data, contract.ProductAnalyticsPerCategoryData{
|
|
CategoryID: item.CategoryID,
|
|
CategoryName: item.CategoryName,
|
|
TotalRevenue: item.TotalRevenue,
|
|
TotalQuantity: item.TotalQuantity,
|
|
ProductCount: item.ProductCount,
|
|
OrderCount: item.OrderCount,
|
|
TotalStandardHpp: item.TotalStandardHpp,
|
|
TotalFifoHpp: item.TotalFifoHpp,
|
|
TotalMovingAverageHpp: item.TotalMovingAverageHpp,
|
|
})
|
|
}
|
|
|
|
return &contract.ProductAnalyticsPerCategoryResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
DateFrom: resp.DateFrom,
|
|
DateTo: resp.DateTo,
|
|
Data: data,
|
|
}
|
|
}
|
|
|
|
// ProductAnalyticsPerParentCategoryContractToModel converts contract request to model
|
|
func ProductAnalyticsPerParentCategoryContractToModel(req *contract.ProductAnalyticsPerParentCategoryRequest) *models.ProductAnalyticsPerParentCategoryRequest {
|
|
var dateFrom, dateTo time.Time
|
|
|
|
// Parse date range using utility function
|
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
|
if fromTime != nil {
|
|
dateFrom = *fromTime
|
|
}
|
|
if toTime != nil {
|
|
dateTo = *toTime
|
|
}
|
|
}
|
|
|
|
return &models.ProductAnalyticsPerParentCategoryRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateFrom: dateFrom,
|
|
DateTo: dateTo,
|
|
}
|
|
}
|
|
|
|
// ProductAnalyticsPerParentCategoryModelToContract converts model response to contract
|
|
func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyticsPerParentCategoryResponse) *contract.ProductAnalyticsPerParentCategoryResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
var data []contract.ProductAnalyticsPerParentCategoryData
|
|
for _, item := range resp.Data {
|
|
data = append(data, contract.ProductAnalyticsPerParentCategoryData{
|
|
ParentCategoryID: item.ParentCategoryID,
|
|
ParentCategoryName: item.ParentCategoryName,
|
|
TotalRevenue: item.TotalRevenue,
|
|
TotalQuantity: item.TotalQuantity,
|
|
CategoryCount: item.CategoryCount,
|
|
ProductCount: item.ProductCount,
|
|
OrderCount: item.OrderCount,
|
|
TotalStandardHpp: item.TotalStandardHpp,
|
|
TotalFifoHpp: item.TotalFifoHpp,
|
|
TotalMovingAverageHpp: item.TotalMovingAverageHpp,
|
|
})
|
|
}
|
|
|
|
return &contract.ProductAnalyticsPerParentCategoryResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
DateFrom: resp.DateFrom,
|
|
DateTo: resp.DateTo,
|
|
Data: data,
|
|
Budget: BudgetCutOffModelToContract(resp.Budget),
|
|
}
|
|
}
|
|
|
|
// budgetPeriodModelToContract converts one budget period to contract
|
|
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
|
|
return contract.BudgetPeriod{
|
|
PeriodStart: period.PeriodStart,
|
|
PeriodEnd: period.PeriodEnd,
|
|
Revenue: period.Revenue,
|
|
OrderCount: period.OrderCount,
|
|
LimitPurchase: period.LimitPurchase,
|
|
LimitOwner: period.LimitOwner,
|
|
LimitTeam: period.LimitTeam,
|
|
}
|
|
}
|
|
|
|
// BudgetCutOffModelToContract converts the budget cut-off block to contract
|
|
func BudgetCutOffModelToContract(budget models.BudgetCutOff) contract.BudgetCutOff {
|
|
weekly := make([]contract.BudgetPeriod, 0, len(budget.Weekly))
|
|
for _, week := range budget.Weekly {
|
|
weekly = append(weekly, budgetPeriodModelToContract(week))
|
|
}
|
|
|
|
monthly := make([]contract.BudgetMonthPeriod, 0, len(budget.Monthly))
|
|
for _, month := range budget.Monthly {
|
|
monthly = append(monthly, contract.BudgetMonthPeriod{
|
|
Month: month.Month,
|
|
WeekCount: month.WeekCount,
|
|
BudgetPeriod: budgetPeriodModelToContract(month.BudgetPeriod),
|
|
})
|
|
}
|
|
|
|
return contract.BudgetCutOff{
|
|
Percentages: contract.BudgetPercentages{
|
|
Purchase: budget.Percentages.Purchase,
|
|
Owner: budget.Percentages.Owner,
|
|
Team: budget.Percentages.Team,
|
|
},
|
|
CutOffFrom: budget.CutOffFrom,
|
|
CutOffTo: budget.CutOffTo,
|
|
Total: budgetPeriodModelToContract(budget.Total),
|
|
Weekly: weekly,
|
|
Monthly: monthly,
|
|
}
|
|
}
|
|
|
|
// ParentCategoryAnalyticsDetailContractToModel converts contract request to model
|
|
func ParentCategoryAnalyticsDetailContractToModel(req *contract.ParentCategoryAnalyticsDetailRequest) *models.ParentCategoryAnalyticsDetailRequest {
|
|
var dateFrom, dateTo time.Time
|
|
|
|
// Parse date range using utility function
|
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
|
if fromTime != nil {
|
|
dateFrom = *fromTime
|
|
}
|
|
if toTime != nil {
|
|
dateTo = *toTime
|
|
}
|
|
}
|
|
|
|
// An unparseable id stays uuid.Nil and is rejected by the service validator
|
|
parentCategoryID, _ := uuid.Parse(req.ParentCategoryID)
|
|
|
|
return &models.ParentCategoryAnalyticsDetailRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
ParentCategoryID: parentCategoryID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateFrom: dateFrom,
|
|
DateTo: dateTo,
|
|
}
|
|
}
|
|
|
|
// ParentCategoryAnalyticsDetailModelToContract converts model response to contract
|
|
func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAnalyticsDetailResponse) *contract.ParentCategoryAnalyticsDetailResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
categories := make([]contract.ParentCategoryAnalyticsDetailData, 0, len(resp.Categories))
|
|
for _, category := range resp.Categories {
|
|
products := make([]contract.ParentCategoryAnalyticsProductData, 0, len(category.Products))
|
|
for _, product := range category.Products {
|
|
products = append(products, contract.ParentCategoryAnalyticsProductData{
|
|
ProductID: product.ProductID,
|
|
ProductName: product.ProductName,
|
|
ProductSku: product.ProductSku,
|
|
ProductPrice: product.ProductPrice,
|
|
QuantitySold: product.QuantitySold,
|
|
WeightSold: product.WeightSold,
|
|
Revenue: product.Revenue,
|
|
AveragePrice: product.AveragePrice,
|
|
OrderCount: product.OrderCount,
|
|
StandardHppPerUnit: product.StandardHppPerUnit,
|
|
StandardHppTotal: product.StandardHppTotal,
|
|
FifoHppPerUnit: product.FifoHppPerUnit,
|
|
FifoHppTotal: product.FifoHppTotal,
|
|
MovingAverageHppPerUnit: product.MovingAverageHppPerUnit,
|
|
MovingAverageHppTotal: product.MovingAverageHppTotal,
|
|
})
|
|
}
|
|
|
|
categories = append(categories, contract.ParentCategoryAnalyticsDetailData{
|
|
CategoryID: category.CategoryID,
|
|
CategoryName: category.CategoryName,
|
|
TotalRevenue: category.TotalRevenue,
|
|
TotalQuantity: category.TotalQuantity,
|
|
ProductCount: category.ProductCount,
|
|
OrderCount: category.OrderCount,
|
|
TotalStandardHpp: category.TotalStandardHpp,
|
|
TotalFifoHpp: category.TotalFifoHpp,
|
|
TotalMovingAverageHpp: category.TotalMovingAverageHpp,
|
|
Products: products,
|
|
})
|
|
}
|
|
|
|
return &contract.ParentCategoryAnalyticsDetailResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
DateFrom: resp.DateFrom,
|
|
DateTo: resp.DateTo,
|
|
ParentCategoryID: resp.ParentCategoryID,
|
|
ParentCategoryName: resp.ParentCategoryName,
|
|
Summary: contract.ParentCategoryAnalyticsDetailSummary{
|
|
TotalRevenue: resp.Summary.TotalRevenue,
|
|
TotalQuantity: resp.Summary.TotalQuantity,
|
|
CategoryCount: resp.Summary.CategoryCount,
|
|
ProductCount: resp.Summary.ProductCount,
|
|
OrderCount: resp.Summary.OrderCount,
|
|
TotalStandardHpp: resp.Summary.TotalStandardHpp,
|
|
TotalFifoHpp: resp.Summary.TotalFifoHpp,
|
|
TotalMovingAverageHpp: resp.Summary.TotalMovingAverageHpp,
|
|
},
|
|
Categories: categories,
|
|
Budget: BudgetCutOffModelToContract(resp.Budget),
|
|
}
|
|
}
|
|
|
|
// DashboardAnalyticsContractToModel converts contract request to model
|
|
func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest {
|
|
var dateFrom, dateTo time.Time
|
|
|
|
// Parse date range using utility function
|
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
|
if fromTime != nil {
|
|
dateFrom = *fromTime
|
|
}
|
|
if toTime != nil {
|
|
dateTo = *toTime
|
|
}
|
|
}
|
|
|
|
return &models.DashboardAnalyticsRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateFrom: dateFrom,
|
|
DateTo: dateTo,
|
|
}
|
|
}
|
|
|
|
// DashboardAnalyticsModelToContract converts model response to contract
|
|
func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) *contract.DashboardAnalyticsResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
var topProducts []contract.ProductAnalyticsData
|
|
for _, item := range resp.TopProducts {
|
|
topProducts = append(topProducts, contract.ProductAnalyticsData{
|
|
ProductID: item.ProductID,
|
|
ProductName: item.ProductName,
|
|
ProductPrice: item.ProductPrice,
|
|
CategoryID: item.CategoryID,
|
|
CategoryName: item.CategoryName,
|
|
QuantitySold: item.QuantitySold,
|
|
WeightSold: item.WeightSold,
|
|
Revenue: item.Revenue,
|
|
AveragePrice: item.AveragePrice,
|
|
OrderCount: item.OrderCount,
|
|
StandardHppPerUnit: item.StandardHppPerUnit,
|
|
StandardHppTotal: item.StandardHppTotal,
|
|
FifoHppPerUnit: item.FifoHppPerUnit,
|
|
FifoHppTotal: item.FifoHppTotal,
|
|
MovingAverageHppPerUnit: item.MovingAverageHppPerUnit,
|
|
MovingAverageHppTotal: item.MovingAverageHppTotal,
|
|
})
|
|
}
|
|
|
|
var paymentMethods []contract.PaymentMethodAnalyticsData
|
|
for _, item := range resp.PaymentMethods {
|
|
paymentMethods = append(paymentMethods, contract.PaymentMethodAnalyticsData{
|
|
PaymentMethodID: item.PaymentMethodID,
|
|
PaymentMethodName: item.PaymentMethodName,
|
|
PaymentMethodType: item.PaymentMethodType,
|
|
TotalAmount: item.TotalAmount,
|
|
OrderCount: item.OrderCount,
|
|
PaymentCount: item.PaymentCount,
|
|
Percentage: item.Percentage,
|
|
})
|
|
}
|
|
|
|
var recentSales []contract.SalesAnalyticsData
|
|
for _, item := range resp.RecentSales {
|
|
recentSales = append(recentSales, contract.SalesAnalyticsData{
|
|
Date: item.Date,
|
|
Sales: item.Sales,
|
|
Orders: item.Orders,
|
|
Items: item.Items,
|
|
Tax: item.Tax,
|
|
Discount: item.Discount,
|
|
NetSales: item.NetSales,
|
|
})
|
|
}
|
|
|
|
return &contract.DashboardAnalyticsResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
DateFrom: resp.DateFrom,
|
|
DateTo: resp.DateTo,
|
|
Overview: contract.DashboardOverview{
|
|
TotalSales: resp.Overview.TotalSales,
|
|
TotalOrders: resp.Overview.TotalOrders,
|
|
AverageOrderValue: resp.Overview.AverageOrderValue,
|
|
TotalCustomers: resp.Overview.TotalCustomers,
|
|
VoidedOrders: resp.Overview.VoidedOrders,
|
|
RefundedOrders: resp.Overview.RefundedOrders,
|
|
TotalItemSold: resp.Overview.TotalItemSold,
|
|
TotalLowStock: resp.Overview.TotalLowStock,
|
|
TotalProductActive: resp.Overview.TotalProductActive,
|
|
},
|
|
TopProducts: topProducts,
|
|
PaymentMethods: paymentMethods,
|
|
RecentSales: recentSales,
|
|
}
|
|
}
|
|
|
|
func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsRequest, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
|
|
dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid date range: %w", err)
|
|
}
|
|
|
|
if dateFrom == nil {
|
|
return nil, fmt.Errorf("date_from is required")
|
|
}
|
|
|
|
if dateTo == nil {
|
|
return nil, fmt.Errorf("date_to is required")
|
|
}
|
|
|
|
return &models.ProfitLossAnalyticsRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateFrom: *dateFrom,
|
|
DateTo: *dateTo,
|
|
GroupBy: req.GroupBy,
|
|
}, nil
|
|
}
|
|
|
|
func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse) *contract.ProfitLossAnalyticsResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
mainSummary := make([]contract.ProfitLossSummaryRow, len(resp.MainSummary))
|
|
for i, row := range resp.MainSummary {
|
|
mainSummary[i] = profitLossSummaryRowModelToContract(row)
|
|
}
|
|
|
|
data := make([]contract.ProfitLossData, len(resp.Data))
|
|
for i, item := range resp.Data {
|
|
data[i] = contract.ProfitLossData{
|
|
Date: item.Date,
|
|
Revenue: item.Revenue,
|
|
Cost: item.Cost,
|
|
GrossProfit: item.GrossProfit,
|
|
GrossProfitMargin: item.GrossProfitMargin,
|
|
Tax: item.Tax,
|
|
Discount: item.Discount,
|
|
NetProfit: item.NetProfit,
|
|
NetProfitMargin: item.NetProfitMargin,
|
|
Orders: item.Orders,
|
|
}
|
|
}
|
|
|
|
productData := make([]contract.ProductProfitData, len(resp.ProductData))
|
|
for i, item := range resp.ProductData {
|
|
productData[i] = contract.ProductProfitData{
|
|
ProductID: item.ProductID,
|
|
ProductName: item.ProductName,
|
|
CategoryID: item.CategoryID,
|
|
CategoryName: item.CategoryName,
|
|
QuantitySold: item.QuantitySold,
|
|
Revenue: item.Revenue,
|
|
Cost: item.Cost,
|
|
GrossProfit: item.GrossProfit,
|
|
GrossProfitMargin: item.GrossProfitMargin,
|
|
AveragePrice: item.AveragePrice,
|
|
AverageCost: item.AverageCost,
|
|
ProfitPerUnit: item.ProfitPerUnit,
|
|
}
|
|
}
|
|
|
|
opsItems := make([]contract.OperationalExpenseItem, len(resp.OperationalExpenses))
|
|
for i, item := range resp.OperationalExpenses {
|
|
opsItems[i] = contract.OperationalExpenseItem{
|
|
Item: item.Item,
|
|
Nominal: item.Nominal,
|
|
}
|
|
}
|
|
|
|
purchasingItems := make([]contract.ProfitLossPurchasingItem, len(resp.Purchasing.Items))
|
|
for i, item := range resp.Purchasing.Items {
|
|
purchasingItems[i] = contract.ProfitLossPurchasingItem{
|
|
Date: item.Date,
|
|
Item: item.Item,
|
|
Quantity: item.Quantity,
|
|
Nominal: item.Nominal,
|
|
}
|
|
}
|
|
|
|
return &contract.ProfitLossAnalyticsResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
DateFrom: resp.DateFrom,
|
|
DateTo: resp.DateTo,
|
|
GroupBy: resp.GroupBy,
|
|
Summary: contract.ProfitLossSummary{
|
|
TotalRevenue: resp.Summary.TotalRevenue,
|
|
TotalCost: resp.Summary.TotalCost,
|
|
GrossProfit: resp.Summary.GrossProfit,
|
|
GrossProfitMargin: resp.Summary.GrossProfitMargin,
|
|
TotalTax: resp.Summary.TotalTax,
|
|
TotalDiscount: resp.Summary.TotalDiscount,
|
|
NetProfit: resp.Summary.NetProfit,
|
|
NetProfitMargin: resp.Summary.NetProfitMargin,
|
|
TotalOrders: resp.Summary.TotalOrders,
|
|
AverageProfit: resp.Summary.AverageProfit,
|
|
ProfitabilityRatio: resp.Summary.ProfitabilityRatio,
|
|
},
|
|
Data: data,
|
|
ProductData: productData,
|
|
MainSummary: mainSummary,
|
|
Purchasing: contract.ProfitLossPurchasing{
|
|
TodayTotal: resp.Purchasing.TodayTotal,
|
|
MtdTotal: resp.Purchasing.MtdTotal,
|
|
TodayRawMaterial: resp.Purchasing.TodayRawMaterial,
|
|
MtdRawMaterial: resp.Purchasing.MtdRawMaterial,
|
|
TodayExpense: resp.Purchasing.TodayExpense,
|
|
MtdExpense: resp.Purchasing.MtdExpense,
|
|
Items: purchasingItems,
|
|
},
|
|
OperationalExpenses: opsItems,
|
|
OperationalExpensesTotal: resp.OperationalExpensesTotal,
|
|
}
|
|
}
|
|
|
|
func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contract.ProfitLossSummaryRow {
|
|
subItems := make([]contract.ProfitLossSummaryRow, len(row.SubItems))
|
|
for i, sub := range row.SubItems {
|
|
subItems[i] = profitLossSummaryRowModelToContract(sub)
|
|
}
|
|
return contract.ProfitLossSummaryRow{
|
|
ID: row.ID,
|
|
Label: row.Label,
|
|
IsBold: row.IsBold,
|
|
TodayNominal: row.TodayNominal,
|
|
TodayPct: row.TodayPct,
|
|
MtdNominal: row.MtdNominal,
|
|
MtdPct: row.MtdPct,
|
|
SubItems: subItems,
|
|
}
|
|
}
|
|
|
|
func ExclusiveSummaryPeriodContractToModel(req *contract.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodRequest, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
|
|
dateFrom, dateTo, err := parseFlexibleDateRangeToJakartaTime(req.DateFrom, req.DateTo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid date range: %w", err)
|
|
}
|
|
|
|
if dateFrom == nil {
|
|
return nil, fmt.Errorf("date_from is required")
|
|
}
|
|
|
|
if dateTo == nil {
|
|
return nil, fmt.Errorf("date_to is required")
|
|
}
|
|
|
|
return &models.ExclusiveSummaryPeriodRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateFrom: *dateFrom,
|
|
DateTo: *dateTo,
|
|
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
|
}, nil
|
|
}
|
|
|
|
func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyRequest, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
|
|
month, err := parseMonthToJakartaTime(req.Month)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid month: %w", err)
|
|
}
|
|
|
|
return &models.ExclusiveSummaryMonthlyRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
Month: month,
|
|
}, nil
|
|
}
|
|
|
|
func ExclusiveSummaryMTDContractToModel(req *contract.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryMTDRequest, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
|
|
dateTo, err := parseFlexibleDateToJakartaTime(req.DateTo, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid date_to: %w", err)
|
|
}
|
|
if dateTo == nil {
|
|
return nil, fmt.Errorf("date_to is required")
|
|
}
|
|
|
|
return &models.ExclusiveSummaryMTDRequest{
|
|
OrganizationID: req.OrganizationID,
|
|
OutletID: parseOutletID(req.OutletID),
|
|
DateTo: *dateTo,
|
|
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
|
}, nil
|
|
}
|
|
|
|
func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
hppBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.HPPBreakdown))
|
|
for i, item := range resp.HPPBreakdown {
|
|
hppBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item)
|
|
}
|
|
|
|
operationalBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.OperationalExpenseBreakdown))
|
|
for i, item := range resp.OperationalExpenseBreakdown {
|
|
operationalBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item)
|
|
}
|
|
|
|
dailySummary := make([]contract.ExclusiveSummaryDailySummary, len(resp.DailySummary))
|
|
for i, item := range resp.DailySummary {
|
|
dailySummary[i] = contract.ExclusiveSummaryDailySummary{
|
|
Date: item.Date,
|
|
TransactionCount: item.TransactionCount,
|
|
TotalCost: item.TotalCost,
|
|
}
|
|
}
|
|
|
|
dailyTransactions := make([]contract.ExclusiveSummaryDailyTransaction, len(resp.DailyTransactions))
|
|
for i, item := range resp.DailyTransactions {
|
|
dailyTransactions[i] = contract.ExclusiveSummaryDailyTransaction{
|
|
Date: item.Date,
|
|
CategoryCode: item.CategoryCode,
|
|
CategoryName: item.CategoryName,
|
|
Description: item.Description,
|
|
Amount: item.Amount,
|
|
Source: item.Source,
|
|
}
|
|
}
|
|
|
|
return &contract.ExclusiveSummaryPeriodResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
Period: contract.ExclusiveSummaryPeriodRange{
|
|
DateFrom: resp.Period.DateFrom,
|
|
DateTo: resp.Period.DateTo,
|
|
},
|
|
Summary: contract.ExclusiveSummaryPeriodSummary{
|
|
Sales: resp.Summary.Sales,
|
|
HPP: resp.Summary.HPP,
|
|
GrossProfit: resp.Summary.GrossProfit,
|
|
SalaryTotal: resp.Summary.SalaryTotal,
|
|
SalaryDW: resp.Summary.SalaryDW,
|
|
SalaryStaff: resp.Summary.SalaryStaff,
|
|
SalaryOther: resp.Summary.SalaryOther,
|
|
OtherOperationalExpenses: resp.Summary.OtherOperationalExpenses,
|
|
OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal,
|
|
TotalCost: resp.Summary.TotalCost,
|
|
NetProfit: resp.Summary.NetProfit,
|
|
},
|
|
Reimburse: contract.ExclusiveSummaryReimburse{
|
|
TotalCost: resp.Reimburse.TotalCost,
|
|
ExcludedSalaryStaff: resp.Reimburse.ExcludedSalaryStaff,
|
|
TotalReimburse: resp.Reimburse.TotalReimburse,
|
|
},
|
|
HPPBreakdown: hppBreakdown,
|
|
OperationalExpenseBreakdown: operationalBreakdown,
|
|
DailySummary: dailySummary,
|
|
DailyTransactions: dailyTransactions,
|
|
}
|
|
}
|
|
|
|
func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthlyResponse) *contract.ExclusiveSummaryMonthlyResponse {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
periods := make([]contract.ExclusiveSummaryMonthlyPeriod, len(resp.Periods))
|
|
for i, item := range resp.Periods {
|
|
periods[i] = contract.ExclusiveSummaryMonthlyPeriod{
|
|
Label: item.Label,
|
|
DateFrom: item.DateFrom,
|
|
DateTo: item.DateTo,
|
|
Sales: item.Sales,
|
|
HPP: item.HPP,
|
|
GrossProfit: item.GrossProfit,
|
|
GrossMargin: item.GrossMargin,
|
|
}
|
|
}
|
|
|
|
bankBalance := make([]contract.ExclusiveSummaryBankBalance, len(resp.BankBalance))
|
|
for i, item := range resp.BankBalance {
|
|
bankBalance[i] = contract.ExclusiveSummaryBankBalance{
|
|
Bank: item.Bank,
|
|
OpeningBalance: item.OpeningBalance,
|
|
IncomingMutation: item.IncomingMutation,
|
|
OutgoingMutation: item.OutgoingMutation,
|
|
ClosingBalance: item.ClosingBalance,
|
|
Notes: item.Notes,
|
|
}
|
|
}
|
|
|
|
return &contract.ExclusiveSummaryMonthlyResponse{
|
|
OrganizationID: resp.OrganizationID,
|
|
OutletID: resp.OutletID,
|
|
OutletName: resp.OutletName,
|
|
Month: resp.Month,
|
|
Summary: contract.ExclusiveSummaryMonthlySummary{
|
|
TotalSales: resp.Summary.TotalSales,
|
|
HPP: resp.Summary.HPP,
|
|
GrossProfit: resp.Summary.GrossProfit,
|
|
OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal,
|
|
TotalCost: resp.Summary.TotalCost,
|
|
NetProfit: resp.Summary.NetProfit,
|
|
NetProfitMargin: resp.Summary.NetProfitMargin,
|
|
},
|
|
Periods: periods,
|
|
BankBalance: bankBalance,
|
|
}
|
|
}
|
|
|
|
func exclusiveSummaryCategoryBreakdownModelToContract(item models.ExclusiveSummaryCategoryBreakdown) contract.ExclusiveSummaryCategoryBreakdown {
|
|
return contract.ExclusiveSummaryCategoryBreakdown{
|
|
CategoryCode: item.CategoryCode,
|
|
CategoryName: item.CategoryName,
|
|
Amount: item.Amount,
|
|
Percentage: item.Percentage,
|
|
}
|
|
}
|
|
|
|
func parseFlexibleDateRangeToJakartaTime(dateFrom, dateTo string) (*time.Time, *time.Time, error) {
|
|
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateFrom, dateTo)
|
|
if err == nil {
|
|
return fromTime, toTime, nil
|
|
}
|
|
|
|
fromTime, err = parseISODateToJakartaTime(dateFrom, false)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
toTime, err = parseISODateToJakartaTime(dateTo, true)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return fromTime, toTime, nil
|
|
}
|
|
|
|
func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
|
|
if dateStr == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
date, err := time.Parse("2006-01-02", dateStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
location, err := time.LoadLocation("Asia/Jakarta")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if endOfDay {
|
|
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location)
|
|
return &result, nil
|
|
}
|
|
|
|
result := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
|
return &result, nil
|
|
}
|
|
|
|
func parseFlexibleDateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
|
|
if dateStr == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateStr, dateStr)
|
|
if err == nil {
|
|
if endOfDay {
|
|
return toTime, nil
|
|
}
|
|
return fromTime, nil
|
|
}
|
|
|
|
return parseISODateToJakartaTime(dateStr, endOfDay)
|
|
}
|
|
|
|
func parseMonthToJakartaTime(month string) (time.Time, error) {
|
|
location, err := time.LoadLocation("Asia/Jakarta")
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
parsed, err := time.ParseInLocation("2006-01", month, location)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location), nil
|
|
}
|