Files
lti-api/internal/modules/closings/services/closing.service.go
T
2025-12-05 19:02:08 +07:00

445 lines
13 KiB
Go

package service
import (
"context"
"errors"
"sort"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
projectFlockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type ClosingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
GetSapronakReport(ctx *fiber.Ctx, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error)
}
type closingService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.ClosingRepository
ProjectFlockKandangRepo projectFlockRepo.ProjectFlockKandangRepository
}
func NewClosingService(
repo repository.ClosingRepository,
projectFlockKandangRepo projectFlockRepo.ProjectFlockKandangRepository,
validate *validator.Validate,
) ClosingService {
return &closingService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
}
}
func (s closingService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser")
}
func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
return db.Where("name LIKE ?", "%"+params.Search+"%")
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get closings: %+v", err)
return nil, 0, err
}
return closings, total, nil
}
func (s closingService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) {
closing, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Closing not found")
}
if err != nil {
s.Log.Errorf("Failed get closing by id: %+v", err)
return nil, err
}
return closing, nil
}
var sapronakFlags = []string{
string(utils.FlagDOC),
string(utils.FlagPakan),
string(utils.FlagOVK),
}
type sapronakIncomingRow struct {
ProductID uint
ProductName string
Flag string
Qty float64
Value float64
DefaultPrice float64
}
type sapronakUsageRow struct {
ProductID uint
ProductName string
Flag string
Qty float64
DefaultPrice float64
}
func (s closingService) GetSapronakReport(c *fiber.Ctx, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, err
}
pfks, err := s.loadProjectFlockKandangs(c, params)
if err != nil {
return nil, err
}
if len(pfks) == 0 {
return []dto.SapronakReportDTO{}, nil
}
startMap, err := s.mapStartDates(c.Context(), pfks)
if err != nil {
s.Log.Errorf("Failed to prepare start dates for sapronak report: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare sapronak report")
}
statusMap, nextStartMap := s.computeStatusAndNextStart(pfks, startMap)
filterStatus := strings.ToLower(strings.TrimSpace(params.Status))
if filterStatus == "" {
filterStatus = "all"
}
results := make([]dto.SapronakReportDTO, 0, len(pfks))
for _, pfk := range pfks {
status := statusMap[pfk.Id]
if status == "" {
status = "closing"
}
if (filterStatus == "active" && status != "active") || (filterStatus == "closing" && status != "closing") {
continue
}
start := startMap[pfk.Id]
var startPtr *time.Time
if !start.IsZero() {
startCopy := start
startPtr = &startCopy
}
var endPtr *time.Time
if end, ok := nextStartMap[pfk.Id]; ok {
endCopy := end
endPtr = &endCopy
}
items, totalIncoming, totalUsage, err := s.buildSapronakItems(c.Context(), pfk, startPtr, endPtr)
if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report")
}
results = append(results, dto.SapronakReportDTO{
ProjectFlockKandangID: pfk.Id,
ProjectFlockID: pfk.ProjectFlockId,
ProjectName: pfk.ProjectFlock.FlockName,
KandangID: pfk.KandangId,
KandangName: pfk.Kandang.Name,
Period: pfk.Period,
Status: status,
StartDate: startPtr,
EndDate: endPtr,
TotalIncomingValue: totalIncoming,
TotalUsageValue: totalUsage,
Items: items,
})
}
sort.Slice(results, func(i, j int) bool {
if results[i].KandangID == results[j].KandangID {
if results[i].Period == results[j].Period {
return results[i].ProjectFlockKandangID < results[j].ProjectFlockKandangID
}
return results[i].Period < results[j].Period
}
return results[i].KandangID < results[j].KandangID
})
return results, nil
}
func (s closingService) loadProjectFlockKandangs(c *fiber.Ctx, params *validation.SapronakQuery) ([]entity.ProjectFlockKandang, error) {
db := s.ProjectFlockKandangRepo.DB().
WithContext(c.Context()).
Preload("ProjectFlock").
Preload("Kandang")
if params != nil {
if params.ProjectFlockID > 0 {
db = db.Where("project_flock_kandangs.project_flock_id = ?", params.ProjectFlockID)
}
if params.KandangID > 0 {
db = db.Where("project_flock_kandangs.kandang_id = ?", params.KandangID)
}
}
var pfks []entity.ProjectFlockKandang
if err := db.Find(&pfks).Error; err != nil {
s.Log.Errorf("Failed to load project flock kandangs for sapronak report: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandangs")
}
return pfks, nil
}
func (s closingService) mapStartDates(ctx context.Context, pfks []entity.ProjectFlockKandang) (map[uint]time.Time, error) {
result := make(map[uint]time.Time, len(pfks))
if len(pfks) == 0 {
return result, nil
}
ids := make([]uint, len(pfks))
for i, pfk := range pfks {
ids[i] = pfk.Id
}
var rows []struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
StartDate *time.Time `gorm:"column:start_date"`
}
if err := s.ProjectFlockKandangRepo.DB().
WithContext(ctx).
Table("project_chickins").
Select("project_flock_kandang_id, MIN(chick_in_date) AS start_date").
Where("project_flock_kandang_id IN ?", ids).
Group("project_flock_kandang_id").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.StartDate != nil {
result[row.ProjectFlockKandangID] = row.StartDate.UTC()
}
}
for _, pfk := range pfks {
if _, exists := result[pfk.Id]; !exists {
result[pfk.Id] = pfk.CreatedAt.UTC()
}
}
return result, nil
}
func (s closingService) computeStatusAndNextStart(pfks []entity.ProjectFlockKandang, startMap map[uint]time.Time) (map[uint]string, map[uint]time.Time) {
statusMap := make(map[uint]string, len(pfks))
nextStartMap := make(map[uint]time.Time, len(pfks))
if len(pfks) == 0 {
return statusMap, nextStartMap
}
grouped := make(map[uint][]entity.ProjectFlockKandang)
for _, pfk := range pfks {
grouped[pfk.KandangId] = append(grouped[pfk.KandangId], pfk)
}
for _, list := range grouped {
sort.Slice(list, func(i, j int) bool {
if list[i].Period == list[j].Period {
return startMap[list[i].Id].Before(startMap[list[j].Id])
}
return list[i].Period < list[j].Period
})
for idx, item := range list {
if idx < len(list)-1 {
next := list[idx+1]
if start, ok := startMap[next.Id]; ok {
nextStartMap[item.Id] = start
}
statusMap[item.Id] = "closing"
continue
}
statusMap[item.Id] = "active"
}
}
return statusMap, nextStartMap
}
func (s closingService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time) ([]dto.SapronakItemDTO, float64, float64, error) {
incoming, err := s.fetchIncomingSapronak(ctx, pfk.KandangId, start, end)
if err != nil {
return nil, 0, 0, err
}
usage, err := s.fetchUsageSapronak(ctx, pfk.Id)
if err != nil {
return nil, 0, 0, err
}
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
for _, row := range incoming {
avgPrice := row.DefaultPrice
if row.Qty > 0 && row.Value > 0 {
avgPrice = row.Value / row.Qty
}
itemMap[row.ProductID] = dto.SapronakItemDTO{
ProductID: row.ProductID,
ProductName: row.ProductName,
Flag: row.Flag,
IncomingQty: row.Qty,
IncomingValue: row.Value,
RemainingQty: row.Qty,
AveragePrice: avgPrice,
}
}
for _, row := range usage {
existing := itemMap[row.ProductID]
price := existing.AveragePrice
if price == 0 {
price = row.DefaultPrice
}
usageValue := row.Qty * price
existing.ProductID = row.ProductID
if existing.ProductName == "" {
existing.ProductName = row.ProductName
}
if existing.Flag == "" {
existing.Flag = row.Flag
}
existing.AveragePrice = price
existing.UsageQty += row.Qty
existing.UsageValue += usageValue
if existing.IncomingQty >= existing.UsageQty {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty
} else {
existing.RemainingQty = 0
}
itemMap[row.ProductID] = existing
}
items := make([]dto.SapronakItemDTO, 0, len(itemMap))
var totalIncoming, totalUsage float64
for _, item := range itemMap {
totalIncoming += item.IncomingValue
totalUsage += item.UsageValue
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].Flag == items[j].Flag {
return strings.ToLower(items[i].ProductName) < strings.ToLower(items[j].ProductName)
}
return items[i].Flag < items[j].Flag
})
return items, totalIncoming, totalUsage, nil
}
func (s closingService) fetchIncomingSapronak(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint]sapronakIncomingRow, error) {
rows := make([]sapronakIncomingRow, 0)
db := s.Repository.DB().
WithContext(ctx).
Table("purchase_items AS pi").
Select(`
pi.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(SUM(pi.total_qty), 0) AS qty,
COALESCE(SUM(pi.total_qty * pi.price), 0) AS value,
COALESCE(p.product_price, 0) AS default_price
`).
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
Joins("JOIN products p ON p.id = pi.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlags).
Where("pi.received_date IS NOT NULL")
if start != nil {
db = db.Where("pi.received_date >= ?", *start)
}
if end != nil {
db = db.Where("pi.received_date < ?", *end)
}
if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]sapronakIncomingRow, len(rows))
for _, row := range rows {
result[row.ProductID] = row
}
return result, nil
}
func (s closingService) fetchUsageSapronak(ctx context.Context, pfkID uint) (map[uint]sapronakUsageRow, error) {
rows := make([]sapronakUsageRow, 0)
if pfkID == 0 {
return map[uint]sapronakUsageRow{}, nil
}
db := s.Repository.DB().
WithContext(ctx).
Table("recording_stocks AS rs").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(SUM(rs.usage_qty), 0) AS qty,
COALESCE(p.product_price, 0) AS default_price
`).
Joins("JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("r.project_flock_kandangs_id = ?", pfkID).
Where("f.name IN ?", sapronakFlags)
if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]sapronakUsageRow, len(rows))
for _, row := range rows {
result[row.ProductID] = row
}
return result, nil
}