Compare commits

..

25 Commits

Author SHA1 Message Date
Giovanni Gabriel Septriadi 1fd3f96038 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!532
2026-05-12 09:31:19 +00:00
Giovanni Gabriel Septriadi cf0fc9e7e6 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!530
2026-05-11 08:32:04 +00:00
Adnan Zahir d9041a89bb Merge branch 'fix/chickin' into 'production'
[FIX][BE]: add migration for edit chickin_date pullet cikaum 1 dan pullet cikaum 2

See merge request mbugroup/lti-api!518
2026-05-08 15:05:02 +07:00
giovanni c75281ebd9 add migration for update day recording pullet cikaum 1 dan 2 2026-05-07 17:35:15 +07:00
Adnan Zahir ca3ad810c6 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!505
2026-05-05 14:12:22 +07:00
Adnan Zahir 655b1ad5fe Merge branch 'development' into 'production'
fix: resolve dashboard OpenAPI integration issues

See merge request mbugroup/lti-api!498
2026-05-03 13:08:58 +07:00
Adnan Zahir 84db5fe37a Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!494
2026-04-29 12:53:02 +07:00
Adnan Zahir 63a78da18d Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!480
2026-04-26 00:13:58 +07:00
Adnan Zahir ac50c06cd7 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!478
2026-04-25 15:26:22 +07:00
Adnan Zahir b60649f59d Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!476
2026-04-25 14:16:20 +07:00
Adnan Zahir 6acc9416c1 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!473
2026-04-24 21:24:56 +07:00
Adnan Zahir bb4e5d6e3e Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!469
2026-04-24 14:20:43 +07:00
Adnan Zahir 170c221957 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!467
2026-04-24 13:31:35 +07:00
Adnan Zahir 812327f148 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!461
2026-04-24 12:30:41 +07:00
Adnan Zahir cd192128f1 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!443
2026-04-23 12:38:24 +07:00
Adnan Zahir a5d4d6c11d Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!436
2026-04-22 13:13:06 +07:00
Adnan Zahir 1452f8d083 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!427
2026-04-20 10:16:45 +07:00
Adnan Zahir 33c6706181 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!425
2026-04-20 08:24:44 +07:00
Adnan Zahir c9618e1095 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!422
2026-04-18 09:47:04 +07:00
Adnan Zahir cae7f3ef63 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!414
2026-04-14 12:01:04 +07:00
Adnan Zahir 42793d94bd Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!412
2026-04-13 14:12:48 +07:00
Adnan Zahir 1369bf0e36 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!411
2026-04-11 14:08:35 +07:00
Adnan Zahir 361d14bd3e Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!406
2026-04-10 10:50:07 +07:00
Adnan Zahir 7923352535 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!401
2026-04-07 22:58:38 +07:00
Adnan Zahir 010240066a Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!399
2026-04-07 16:52:45 +07:00
52 changed files with 436 additions and 3733 deletions
@@ -1,5 +0,0 @@
BEGIN;
DROP TABLE IF EXISTS daily_checklist_empty_kandangs;
COMMIT;
@@ -1,60 +0,0 @@
BEGIN;
CREATE TABLE daily_checklist_empty_kandangs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
daily_checklist_id bigint NOT NULL,
kandang_id bigint NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
created_by bigint,
deleted_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT fk_dcek_daily_checklist
FOREIGN KEY (daily_checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_dcek_deleted_by
FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT ck_dcek_range CHECK (end_date >= start_date)
);
CREATE INDEX idx_dcek_kandang_range
ON daily_checklist_empty_kandangs (kandang_id, start_date, end_date)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_dcek_daily_checklist_unique
ON daily_checklist_empty_kandangs (daily_checklist_id)
WHERE deleted_at IS NULL;
INSERT INTO daily_checklist_empty_kandangs (
daily_checklist_id, kandang_id, start_date, end_date, created_by, created_at, updated_at
)
SELECT
dc.id,
dc.kandang_id,
dc.date AS start_date,
COALESCE(
(SELECT (next_dc.date - INTERVAL '1 day')::date
FROM daily_checklists next_dc
WHERE next_dc.kandang_id = dc.kandang_id
AND next_dc.date > dc.date
AND next_dc.category <> 'empty_kandang'
AND (next_dc.status IS NULL OR next_dc.status <> 'REJECTED')
AND next_dc.deleted_at IS NULL
ORDER BY next_dc.date ASC
LIMIT 1),
dc.date
) AS end_date,
dc.created_by,
dc.created_at,
dc.updated_at
FROM daily_checklists dc
WHERE dc.category = 'empty_kandang'
AND dc.deleted_at IS NULL;
COMMIT;
@@ -1,2 +0,0 @@
ALTER TABLE customers DROP COLUMN bank_name;
ALTER TABLE suppliers DROP COLUMN bank_name;
@@ -1,2 +0,0 @@
ALTER TABLE customers ADD COLUMN bank_name VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE suppliers ADD COLUMN bank_name VARCHAR(100);
-1
View File
@@ -15,7 +15,6 @@ type Customer struct {
Phone string `gorm:"not null;size:20"`
Email string `gorm:"type:varchar(50);not null"`
AccountNumber string `gorm:"not null;size:50"`
BankName string `gorm:"not null;size:100;default:''"`
Balance float64 `gorm:"default:0"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
@@ -1,27 +0,0 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandang struct {
Id uint `gorm:"primaryKey"`
DailyChecklistId uint `gorm:"not null"`
KandangId uint `gorm:"not null"`
StartDate time.Time `gorm:"type:date;not null"`
EndDate time.Time `gorm:"type:date;not null"`
CreatedBy *uint
DeletedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
DailyChecklist *DailyChecklist `gorm:"foreignKey:DailyChecklistId;references:Id"`
Kandang *KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
}
func (DailyChecklistEmptyKandang) TableName() string {
return "daily_checklist_empty_kandangs"
}
+5 -6
View File
@@ -23,12 +23,11 @@ type DailyChecklist struct {
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
EmptyKandang *DailyChecklistEmptyKandang `gorm:"foreignKey:DailyChecklistId;references:Id"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
}
type DailyChecklistPhase struct {
-1
View File
@@ -19,7 +19,6 @@ type Supplier struct {
Address string `gorm:"not null"`
Npwp *string `gorm:"size:50"`
AccountNumber *string `gorm:"size:50"`
BankName *string `gorm:"size:100"`
Balance float64 `gorm:"type:numeric(15,3);default:0"`
DueDate int `gorm:"not null"`
CreatedBy uint `gorm:"not null"`
@@ -42,13 +42,6 @@ type DailyChecklistDetailDTO struct {
TotalActivity int `json:"total_activity"`
Progress float64 `json:"progress"`
DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"`
EmptyKandang *DailyChecklistEmptyKandangDTO `json:"empty_kandang,omitempty"`
}
type DailyChecklistEmptyKandangDTO struct {
Id uint `json:"id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
}
type DailyChecklistDocumentDTO struct {
@@ -187,17 +180,6 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO {
}
}
func ToDailyChecklistEmptyKandangDTO(e *entity.DailyChecklistEmptyKandang) *DailyChecklistEmptyKandangDTO {
if e == nil || e.Id == 0 {
return nil
}
return &DailyChecklistEmptyKandangDTO{
Id: e.Id,
StartDate: e.StartDate,
EndDate: e.EndDate,
}
}
func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO {
phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases))
for _, phase := range phases {
@@ -259,6 +241,5 @@ func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.
TotalActivity: totalActivities,
Progress: progress,
DocumentURLs: documentURLs,
EmptyKandang: ToDailyChecklistEmptyKandangDTO(checklist.EmptyKandang),
}
}
+1 -2
View File
@@ -22,7 +22,6 @@ type DailyChecklistModule struct{}
func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db)
emptyKandangRepo := rDailyChecklist.NewDailyChecklistEmptyKandangRepository(db)
phasesRepo := rPhases.NewPhasesRepository(db)
userRepo := rUser.NewUserRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
@@ -31,7 +30,7 @@ func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
panic(fmt.Sprintf("failed to create document service: %v", err))
}
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, emptyKandangRepo, phasesRepo, validate, documentSvc)
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate, documentSvc)
userService := sUser.NewUserService(userRepo, validate)
DailyChecklistRoutes(router, userService, dailyChecklistService)
@@ -1,98 +0,0 @@
package repository
import (
"context"
"errors"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandangRepository interface {
repository.BaseRepository[entity.DailyChecklistEmptyKandang]
FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error)
FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error)
SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error
}
type DailyChecklistEmptyKandangRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.DailyChecklistEmptyKandang]
}
func NewDailyChecklistEmptyKandangRepository(db *gorm.DB) DailyChecklistEmptyKandangRepository {
return &DailyChecklistEmptyKandangRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklistEmptyKandang](db),
}
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("daily_checklist_id = ?", dailyChecklistID).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
query := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate)
if excludeDailyChecklistID > 0 {
query = query.Where("daily_checklist_id <> ?", excludeDailyChecklistID)
}
if err := query.First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error) {
if len(kandangIDs) == 0 {
return []entity.DailyChecklistEmptyKandang{}, nil
}
var recs []entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?", kandangIDs, rangeEnd, rangeStart).
Find(&recs).Error; err != nil {
return nil, err
}
return recs, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error {
updates := map[string]any{
"deleted_at": time.Now(),
}
if actorID != nil {
updates["deleted_by"] = *actorID
}
return r.DB().WithContext(ctx).
Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", dailyChecklistID).
Updates(updates).Error
}
@@ -44,12 +44,11 @@ type DailyChecklistService interface {
}
type dailyChecklistService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.DailyChecklistRepository
EmptyKandangRepo repository.DailyChecklistEmptyKandangRepository
PhaseRepo phaseRepo.PhasesRepository
DocumentSvc commonSvc.DocumentService
Log *logrus.Logger
Validate *validator.Validate
Repository repository.DailyChecklistRepository
PhaseRepo phaseRepo.PhasesRepository
DocumentSvc commonSvc.DocumentService
}
type DailyChecklistDocument struct {
@@ -131,24 +130,20 @@ const (
dailyChecklistStatusDraft = "DRAFT"
dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist"
dailyChecklistErrDeletedNonEmptyKandangExists = "DailyChecklist cannot be created as empty_kandang because a deleted non-empty_kandang checklist exists for this date"
dailyChecklistErrEmptyKandangRangeOverlap = "Empty kandang range overlaps with an existing empty kandang period for this kandang"
dailyChecklistErrDateInsideEmptyKandang = "Tanggal berada dalam periode kandang kosong untuk kandang ini"
dailyChecklistErrEmptyKandangEndDateInvalid = "empty_kandang_end_date harus >= date"
)
func NewDailyChecklistService(repo repository.DailyChecklistRepository, emptyKandangRepo repository.DailyChecklistEmptyKandangRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
return &dailyChecklistService{
Log: utils.Log,
Validate: validate,
Repository: repo,
EmptyKandangRepo: emptyKandangRepo,
PhaseRepo: phaseRepo,
DocumentSvc: documentSvc,
Log: utils.Log,
Validate: validate,
Repository: repo,
PhaseRepo: phaseRepo,
DocumentSvc: documentSvc,
}
}
func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("Kandang").Preload("EmptyKandang")
return db.Preload("Kandang")
}
func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error {
@@ -534,23 +529,6 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
category = dailyChecklistCategoryEmptyKandang
}
var emptyEndDate time.Time
if category == dailyChecklistCategoryEmptyKandang {
trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate)
if trimmedEnd == "" {
emptyEndDate = date
} else {
parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd)
if parseErr != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
}
if parsedEnd.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid)
}
emptyEndDate = parsedEnd
}
}
targetID := uint(0)
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
@@ -559,39 +537,25 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
}
if category == dailyChecklistCategoryEmptyKandang {
if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoExistingEmptyKandangInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, date); err != nil {
return err
}
if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil {
return err
}
} else {
if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, 0); err != nil {
conflictID := uint(0)
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date, category, status, &conflictID); err != nil {
return err
}
if err := s.validateDateNotInExistingEmptyKandangChecklist(tx, req.KandangId, date, 0); err != nil {
return err
if conflictID > 0 {
targetID = conflictID
return nil
}
}
if err := s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID); err != nil {
return err
}
if category == dailyChecklistCategoryEmptyKandang {
actorID, _ := m.ActorIDFromContext(c)
if err := s.upsertEmptyKandangRange(tx, targetID, req.KandangId, date, emptyEndDate, actorID); err != nil {
return err
}
}
return nil
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
})
if err != nil {
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
@@ -621,120 +585,43 @@ func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kan
return nil
}
func (s *dailyChecklistService) validateNoNormalChecklistInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category <> ? AND deleted_at IS NULL",
kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error {
var conflictCount int64
if err := q.Count(&conflictCount).Error; err != nil {
if err := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", kandangID, startDate, endDate).
Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist)
}
return nil
}
func (s *dailyChecklistService) validateNoEmptyKandangRangeOverlap(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate)
if excludeDCID > 0 {
q = q.Where("daily_checklist_id <> ?", excludeDCID)
}
var overlapCount int64
if err := q.Count(&overlapCount).Error; err != nil {
return err
}
if overlapCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap)
}
return nil
}
func (s *dailyChecklistService) validateDateNotInEmptyKandangRange(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date)
if excludeDCID > 0 {
q = q.Where("daily_checklist_id <> ?", excludeDCID)
}
var rec entity.DailyChecklistEmptyKandang
if err := q.First(&rec).Error; err != nil {
func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, newCategory, newStatus string, conflictID *uint) error {
var existing entity.DailyChecklist
if err := tx.Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL",
kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang).
First(&existing).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang)
}
func (s *dailyChecklistService) validateNoExistingEmptyKandangInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL",
kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64
if err := q.Count(&conflictCount).Error; err != nil {
if err := tx.Model(&entity.DailyChecklist{}).Where("id = ?", existing.Id).Updates(map[string]interface{}{
"category": newCategory,
"status": newStatus,
}).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap)
}
*conflictID = existing.Id
return nil
}
func (s *dailyChecklistService) validateDateNotInExistingEmptyKandangChecklist(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL",
kandangID, date, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64
if err := q.Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang)
}
return nil
}
func (s *dailyChecklistService) upsertEmptyKandangRange(tx *gorm.DB, dailyChecklistID, kandangID uint, startDate, endDate time.Time, actorID uint) error {
var existing entity.DailyChecklistEmptyKandang
err := tx.Where("daily_checklist_id = ?", dailyChecklistID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil {
return tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("id = ?", existing.Id).
Updates(map[string]any{
"kandang_id": kandangID,
"start_date": startDate,
"end_date": endDate,
"updated_at": time.Now(),
}).Error
}
record := &entity.DailyChecklistEmptyKandang{
DailyChecklistId: dailyChecklistID,
KandangId: kandangID,
StartDate: startDate,
EndDate: endDate,
}
if actorID > 0 {
actor := actorID
record.CreatedBy = &actor
}
return tx.Create(record).Error
}
func (s *dailyChecklistService) validateNoDeletedNonEmptyKandangForDate(tx *gorm.DB, kandangID uint, date time.Time) error {
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
@@ -1013,53 +900,11 @@ func (s *dailyChecklistService) UpdateByPut(c *fiber.Ctx, req *validation.Create
status := req.Status
var emptyEndDate time.Time
if category == dailyChecklistCategoryEmptyKandang {
trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate)
if trimmedEnd == "" {
emptyEndDate = date
} else {
parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd)
if parseErr != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
}
if parsedEnd.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid)
}
emptyEndDate = parsedEnd
}
}
var wasBranchC bool // non-empty_kandang → empty_kandang transition
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil {
return err
}
var existing entity.DailyChecklist
if err := tx.Where("id = ? AND deleted_at IS NULL", id).First(&existing).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return err
}
existingIsEmpty := existing.Category == dailyChecklistCategoryEmptyKandang
newIsEmpty := category == dailyChecklistCategoryEmptyKandang
if newIsEmpty {
if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, id); err != nil {
return err
}
if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, id); err != nil {
return err
}
} else {
if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, id); err != nil {
return err
}
}
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("id <> ? AND date = ? AND kandang_id = ? AND category = ? AND deleted_at IS NULL",
@@ -1083,63 +928,12 @@ func (s *dailyChecklistService) UpdateByPut(c *fiber.Ctx, req *validation.Create
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
actorID, _ := m.ActorIDFromContext(c)
if newIsEmpty {
if err := s.upsertEmptyKandangRange(tx, id, req.KandangId, date, emptyEndDate, actorID); err != nil {
return err
}
// Branch C: non-empty → empty_kandang, hard-delete task/progress data
if !existingIsEmpty {
wasBranchC = true
if err := tx.Exec(`
DELETE FROM daily_checklist_activity_task_assignments
WHERE task_id IN (
SELECT id FROM daily_checklist_activity_tasks WHERE checklist_id = ?
)`, id).Error; err != nil {
return err
}
if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistActivityTask{}).Error; err != nil {
return err
}
if err := tx.Where("daily_checklist_id = ?", id).Delete(&entity.DailyChecklistTask{}).Error; err != nil {
return err
}
}
} else if existingIsEmpty {
updates := map[string]any{
"deleted_at": time.Now(),
}
if actorID > 0 {
updates["deleted_by"] = actorID
}
if err := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", id).
Updates(updates).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
// Branch C: delete DC documents outside transaction (storage is external)
if wasBranchC && s.DocumentSvc != nil {
docs, docErr := s.DocumentSvc.ListByTarget(c.Context(), string(utils.DocumentTypeDailyChecklist), uint64(id))
if docErr == nil && len(docs) > 0 {
docIDs := make([]uint, 0, len(docs))
for _, doc := range docs {
docIDs = append(docIDs, doc.Id)
}
if delErr := s.DocumentSvc.DeleteDocuments(c.Context(), docIDs, true); delErr != nil {
s.Log.Errorf("Failed to delete documents for DC %d during empty_kandang conversion: %+v", id, delErr)
}
}
}
return s.GetOne(c, id)
}
@@ -1174,15 +968,6 @@ func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
return gorm.ErrRecordNotFound
}
if err := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", id).
Updates(map[string]any{
"deleted_at": time.Now(),
"deleted_by": actorID,
}).Error; err != nil {
return err
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1910,43 +1695,92 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
}
firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC)
lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1)
today := time.Now().UTC().Truncate(24 * time.Hour)
type emptyRangeRec struct {
type emptyKandangRec struct {
KandangID uint
StartDate time.Time
EndDate time.Time
Date time.Time
}
var rangeRecs []emptyRangeRec
var emptyRecs []emptyKandangRec
if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?",
kandangIDs, lastDay, firstDay).
Select("kandang_id, start_date, end_date").
Scan(&rangeRecs).Error; err != nil {
s.Log.Errorf("Failed to get empty kandang ranges for report: %+v", err)
Model(&entity.DailyChecklist{}).
Where("kandang_id IN ? AND category = ? AND date <= ? AND deleted_at IS NULL",
kandangIDs, dailyChecklistCategoryEmptyKandang, lastDay).
Select("kandang_id, date").
Scan(&emptyRecs).Error; err != nil {
s.Log.Errorf("Failed to get empty kandang records for report: %+v", err)
return err
}
emptyDaysByKandang := make(map[uint]map[int]struct{})
for _, rec := range rangeRecs {
effectiveStart := rec.StartDate
if effectiveStart.Before(firstDay) {
effectiveStart = firstDay
}
effectiveEnd := rec.EndDate
if effectiveEnd.After(lastDay) {
effectiveEnd = lastDay
}
if effectiveStart.After(effectiveEnd) {
continue
if len(emptyRecs) > 0 {
minEmptyDate := emptyRecs[0].Date
for _, rec := range emptyRecs[1:] {
if rec.Date.Before(minEmptyDate) {
minEmptyDate = rec.Date
}
}
if _, ok := emptyDaysByKandang[rec.KandangID]; !ok {
emptyDaysByKandang[rec.KandangID] = make(map[int]struct{})
type checklistDateRec struct {
KandangID uint
Date time.Time
}
for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) {
emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{}
var nextDates []checklistDateRec
if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.DailyChecklist{}).
Where("kandang_id IN ? AND category != ? AND date > ? AND (status IS NULL OR status != ?) AND deleted_at IS NULL",
kandangIDs, dailyChecklistCategoryEmptyKandang, minEmptyDate, dailyChecklistStatusRejected).
Select("kandang_id, date").
Order("kandang_id ASC, date ASC").
Scan(&nextDates).Error; err != nil {
s.Log.Errorf("Failed to get next checklist dates for empty kandang: %+v", err)
return err
}
nextDatesByKandang := make(map[uint][]time.Time)
for _, row := range nextDates {
nextDatesByKandang[row.KandangID] = append(nextDatesByKandang[row.KandangID], row.Date)
}
for _, rec := range emptyRecs {
var nextDate time.Time
for _, d := range nextDatesByKandang[rec.KandangID] {
if d.After(rec.Date) {
nextDate = d
break
}
}
// If no next checklist, cap empty period at today (not end of month)
ceiling := lastDay
if today.Before(lastDay) {
ceiling = today
}
periodEnd := ceiling
if !nextDate.IsZero() {
periodEnd = nextDate.AddDate(0, 0, -1)
}
effectiveStart := rec.Date
if effectiveStart.Before(firstDay) {
effectiveStart = firstDay
}
effectiveEnd := periodEnd
if effectiveEnd.After(lastDay) {
effectiveEnd = lastDay
}
if effectiveStart.After(effectiveEnd) {
continue
}
if _, ok := emptyDaysByKandang[rec.KandangID]; !ok {
emptyDaysByKandang[rec.KandangID] = make(map[int]struct{})
}
for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) {
emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{}
}
}
}
@@ -208,18 +208,8 @@ func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t *
Count(&activeInRange).Error; err != nil {
t.Fatalf("failed counting checklists in range: %v", err)
}
if activeInRange != 1 {
t.Fatalf("expected 1 active empty_kandang checklist created for range, got %d", activeInRange)
}
var emptyRangeCount int64
if err := db.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date = ? AND end_date = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
Count(&emptyRangeCount).Error; err != nil {
t.Fatalf("failed counting empty kandang ranges: %v", err)
}
if emptyRangeCount != 1 {
t.Fatalf("expected 1 empty kandang range record for [2026-01-01, 2026-01-05], got %d", emptyRangeCount)
if activeInRange != 5 {
t.Fatalf("expected 5 active checklists created for range, got %d", activeInRange)
}
}
@@ -314,18 +304,6 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE daily_checklist_empty_kandangs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
daily_checklist_id INTEGER NOT NULL,
kandang_id INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_by INTEGER NULL,
deleted_by INTEGER NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
@@ -338,8 +316,7 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.
}
repo := repository.NewDailyChecklistRepository(db)
emptyRepo := repository.NewDailyChecklistEmptyKandangRepository(db)
svc := NewDailyChecklistService(repo, emptyRepo, nil, validator.New(), nil)
svc := NewDailyChecklistService(repo, nil, validator.New(), nil)
return svc, db
}
@@ -9,8 +9,7 @@ type Create struct {
KandangId uint `json:"kandang_id" validate:"required"`
Category string `json:"category" validate:"required"`
Status string `json:"status" validate:"required"`
EmptyKandang bool `json:"empty_kandang"`
EmptyKandangEndDate string `json:"empty_kandang_end_date" validate:"omitempty"`
EmptyKandang bool `json:"empty_kandang"`
}
type Update struct {
@@ -65,8 +65,6 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")),
ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)),
SortBy: strings.TrimSpace(c.Query("sort_by", "")),
SortOrder: strings.TrimSpace(c.Query("sort_order", "")),
}
if isAllExpenseExcelExportRequest(c) {
@@ -98,7 +98,6 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return db.
Preload("Expense").
Preload("Expense.Supplier").
Preload("Expense.Location").
Preload("Kandang").
Preload("Kandang.Location").
Preload("Nonstock").
@@ -178,48 +177,10 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return nil, 0, err
}
sortExpr := "expense_realizations.created_at"
order := "DESC"
if filters.SortOrder == "asc" {
order = "ASC"
}
switch filters.SortBy {
case "po_number":
sortExpr = "expenses.po_number"
case "reference_number":
sortExpr = "expenses.reference_number"
case "realization_date":
sortExpr = "expenses.realization_date"
case "transaction_date":
sortExpr = "expenses.transaction_date"
case "category":
sortExpr = "expenses.category"
case "product":
sortExpr = "(SELECT name FROM nonstocks WHERE id = expense_nonstocks.nonstock_id)"
case "supplier":
sortExpr = "suppliers.name"
case "location":
sortExpr = "(SELECT l.name FROM kandangs k JOIN locations l ON l.id = k.location_id WHERE k.id = expense_nonstocks.kandang_id)"
case "kandang":
sortExpr = "(SELECT name FROM kandangs WHERE id = expense_nonstocks.kandang_id)"
case "qty_pengajuan":
sortExpr = "expense_nonstocks.qty"
case "price_pengajuan":
sortExpr = "expense_nonstocks.price"
case "total_pengajuan":
sortExpr = "expense_nonstocks.qty * expense_nonstocks.price"
case "qty_realisasi":
sortExpr = "expense_realizations.qty"
case "price_realisasi":
sortExpr = "expense_realizations.price"
case "total_realisasi":
sortExpr = "expense_realizations.qty * expense_realizations.price"
}
if err := db.
Offset(offset).
Limit(limit).
Order(sortExpr + " " + order).
Order("expense_realizations.created_at DESC").
Find(&realizations).Error; err != nil {
return nil, 0, err
}
@@ -289,40 +289,7 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
like,
)
}
sortBy := strings.TrimSpace(params.SortBy)
sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder))
if sortOrder == "" {
sortOrder = "DESC"
}
switch sortBy {
case "reference_number":
return db.Order("expenses.reference_number " + sortOrder)
case "transaction_date":
return db.Order("expenses.transaction_date " + sortOrder)
case "realization_date":
return db.Order("expenses.realization_date " + sortOrder)
case "location":
return db.Order("(SELECT COALESCE(name,'') FROM locations WHERE id = expenses.location_id) " + sortOrder)
case "created_user":
return db.Order("(SELECT COALESCE(name,'') FROM users WHERE id = expenses.created_by) " + sortOrder)
case "supplier":
return db.Order("(SELECT COALESCE(name,'') FROM suppliers WHERE id = expenses.supplier_id) " + sortOrder)
case "grand_total":
return db.Order(`(SELECT COALESCE(
(SELECT SUM(er.qty * er.price) FROM expense_realizations er
JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id
WHERE en.expense_id = expenses.id),
(SELECT SUM(en2.qty * en2.price) FROM expense_nonstocks en2
WHERE en2.expense_id = expenses.id),
0)) ` + sortOrder)
case "is_paid":
return db.Order("expenses.is_paid " + sortOrder)
case "created_at":
return db.Order("expenses.created_at " + sortOrder)
default:
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
}
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
})
if scopeErr != nil {
@@ -54,8 +54,6 @@ type Query struct {
RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"`
ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=reference_number transaction_date realization_date location created_user supplier grand_total is_paid created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
}
type CreateRealization struct {
@@ -5,7 +5,6 @@ import (
"strconv"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations"
@@ -14,8 +13,6 @@ import (
"github.com/gofiber/fiber/v2"
)
const transactionExcelExportFetchLimit = 99999999
type TransactionController struct {
TransactionService service.TransactionService
}
@@ -100,8 +97,6 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
CustomerIDs: customerIDs,
SupplierIDs: supplierIDs,
SortDate: c.Query("sort_date", ""),
SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""),
StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""),
}
@@ -110,14 +105,6 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
if isTransactionExcelExportRequest(c) {
results, err := u.getAllTransactionsForExcel(c, query)
if err != nil {
return err
}
return exportTransactionListExcel(c, results)
}
result, totalResults, err := u.TransactionService.GetAll(c, query)
if err != nil {
return err
@@ -160,32 +147,6 @@ func (u *TransactionController) GetOne(c *fiber.Ctx) error {
})
}
func isTransactionExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func (u *TransactionController) getAllTransactionsForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]entity.Payment, error) {
query := *baseQuery
query.Page = 1
query.Limit = transactionExcelExportFetchLimit
results := make([]entity.Payment, 0)
for {
pageResults, total, err := u.TransactionService.GetAll(c, &query)
if err != nil {
return nil, err
}
if len(pageResults) == 0 || total == 0 {
break
}
results = append(results, pageResults...)
if int64(len(results)) >= total {
break
}
query.Page++
}
return results, nil
}
func (u *TransactionController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
@@ -1,307 +0,0 @@
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
const transactionExportSheetName = "Transaksi"
func exportTransactionListExcel(c *fiber.Ctx, payments []entity.Payment) error {
content, err := buildTransactionExportWorkbook(payments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("transaksi_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func buildTransactionExportWorkbook(payments []entity.Payment) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != transactionExportSheetName {
if err := file.SetSheetName(defaultSheet, transactionExportSheetName); err != nil {
return nil, err
}
}
if err := setTransactionExportColumns(file); err != nil {
return nil, err
}
if err := setTransactionExportHeaders(file); err != nil {
return nil, err
}
if err := setTransactionExportRows(file, payments); err != nil {
return nil, err
}
if err := file.SetPanes(transactionExportSheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setTransactionExportColumns(file *excelize.File) error {
columnWidths := map[string]float64{
"A": 20,
"B": 22,
"C": 18,
"D": 25,
"E": 14,
"F": 16,
"G": 16,
"H": 22,
"I": 22,
"J": 18,
"K": 18,
"L": 18,
"M": 30,
"N": 22,
"O": 20,
}
sheet := transactionExportSheetName
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setTransactionExportHeaders(file *excelize.File) error {
sheet := transactionExportSheetName
headers := []string{
"Kode Pembayaran",
"No. Referensi",
"Tipe Transaksi",
"Pihak",
"Tipe Pihak",
"Tanggal Bayar",
"Metode Bayar",
"Bank",
"No. Rekening Bank",
"Pemasukan",
"Pengeluaran",
"Nominal",
"Catatan",
"Dibuat Oleh",
"Status",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+"1", header); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "A1", "O1", headerStyle)
}
func setTransactionExportRows(file *excelize.File, payments []entity.Payment) error {
if len(payments) == 0 {
return nil
}
sheet := transactionExportSheetName
for i, p := range payments {
row := strconv.Itoa(i + 2)
if err := writeTransactionExportRow(file, sheet, row, p); err != nil {
return err
}
}
lastRow := strconv.Itoa(len(payments) + 1)
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A2", "O"+lastRow, dataStyle); err != nil {
return err
}
numericStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "J2", "L"+lastRow, numericStyle)
}
func writeTransactionExportRow(file *excelize.File, sheet, row string, p entity.Payment) error {
incomeAmount, expenseAmount := txAmounts(p.Direction, p.Nominal)
values := []interface{}{
safeTxText(p.PaymentCode),
safeTxRefNumber(p.ReferenceNumber),
safeTxText(txTransactionType(p)),
safeTxText(txPartyName(p)),
safeTxText(p.PartyType),
formatTxDate(p.PaymentDate),
safeTxText(p.PaymentMethod),
safeTxBank(p),
safeTxBankAccount(p),
incomeAmount,
expenseAmount,
p.Nominal,
safeTxText(p.Notes),
safeTxText(txCreatedBy(p)),
formatTxStatus(p),
}
for colIdx, val := range values {
colName, err := excelize.ColumnNumberToName(colIdx + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+row, val); err != nil {
return err
}
}
return nil
}
func safeTxText(s string) string {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return "-"
}
return trimmed
}
func safeTxRefNumber(s *string) string {
if s == nil {
return "-"
}
return safeTxText(*s)
}
func safeTxBank(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.Name)
}
func safeTxBankAccount(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.AccountNumber)
}
func formatTxDate(t time.Time) string {
if t.IsZero() {
return "-"
}
loc, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(loc)
}
return t.Format("02-01-2006")
}
func formatTxStatus(p entity.Payment) string {
if p.LatestApproval == nil {
return "-"
}
return safeTxText(p.LatestApproval.StepName)
}
func txTransactionType(p entity.Payment) string {
if p.TransactionType != "" {
return p.TransactionType
}
return p.Direction
}
func txPartyName(p entity.Payment) string {
switch p.PartyType {
case "CUSTOMER":
if p.Customer != nil && p.Customer.Id != 0 {
return p.Customer.Name
}
case "SUPPLIER":
if p.Supplier != nil && p.Supplier.Id != 0 {
return p.Supplier.Name
}
}
return ""
}
func txCreatedBy(p entity.Payment) string {
if p.CreatedUser.Id == 0 {
return ""
}
return p.CreatedUser.Name
}
func txAmounts(direction string, nominal float64) (income, expense float64) {
switch strings.ToUpper(direction) {
case "IN":
return nominal, 0
case "OUT":
return 0, nominal
default:
return 0, 0
}
}
@@ -72,26 +72,19 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
needsPartyJoin := params.Search != "" || params.SortBy == "customer_name"
needsBankJoin := params.Search != "" || params.SortBy == "bank"
if needsPartyJoin {
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
db = db.Joins(
"LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL",
string(utils.PaymentPartyCustomer),
).Joins(
"LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL",
string(utils.PaymentPartySupplier),
).Joins(
"LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL",
)
}
if needsBankJoin {
db = db.Joins("LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL")
}
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
db = db.Where(
`(LOWER(payment_code) LIKE ? OR
`LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(payment_method, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR
@@ -100,7 +93,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
LOWER(COALESCE(suppliers.name, '')) LIKE ? OR
LOWER(COALESCE(banks.name, '')) LIKE ? OR
CAST(payments.nominal AS TEXT) LIKE ? OR
TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?)`,
TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?`,
like, like, like, like, like, like, like, like, like, like,
)
}
@@ -145,7 +138,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
db = db.Where("payment_date < ?", *endDate)
}
return applyTransactionSort(db, params.SortBy, params.SortOrder, params.SortDate)
return applyTransactionSort(db, params.SortDate)
})
if err != nil {
@@ -277,39 +270,13 @@ func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Tim
return startPtr, endPtr, nil
}
func applyTransactionSort(db *gorm.DB, sortBy, sortOrder, sortDate string) *gorm.DB {
order := "DESC"
if strings.ToUpper(strings.TrimSpace(sortOrder)) == "ASC" {
order = "ASC"
}
switch strings.ToLower(strings.TrimSpace(sortBy)) {
case "payment_code":
return db.Order("payments.payment_code " + order)
case "reference_number":
return db.Order("payments.reference_number " + order)
case "transaction_type":
return db.Order("payments.transaction_type " + order)
case "customer_name":
return db.Order("COALESCE(customers.name, suppliers.name) " + order)
case "payment_date":
return db.Order("payments.payment_date " + order)
case "created_at":
return db.Order("payments.created_at " + order)
case "payment_method":
return db.Order("payments.payment_method " + order)
case "bank":
return db.Order("banks.account_number " + order)
case "expense_amount":
return db.Order("CASE WHEN payments.direction = 'OUT' THEN payments.nominal ELSE 0 END " + order)
case "income_amount":
return db.Order("CASE WHEN payments.direction = 'IN' THEN payments.nominal ELSE 0 END " + order)
}
func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB {
switch strings.ToLower(strings.TrimSpace(sortDate)) {
case "created_at":
return db.Order("payments.created_at DESC").Order("payments.payment_date DESC")
return db.Order("created_at DESC").Order("payment_date DESC")
case "payment_date":
return db.Order("payment_date DESC").Order("created_at DESC")
default:
return db.Order("payments.payment_date DESC").Order("payments.created_at DESC")
return db.Order("payment_date DESC").Order("created_at DESC")
}
}
@@ -10,15 +10,13 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"`
BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=payment_code reference_number transaction_type customer_name payment_date created_at payment_method bank expense_amount income_amount"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
}
@@ -2,6 +2,7 @@ package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -152,7 +153,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), sumMarketingGrandTotal(item.SalesOrder)); err != nil {
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil {
@@ -265,6 +266,40 @@ func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 {
return total
}
func formatMarketingRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value)
@@ -310,8 +310,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
return db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id").Order("COALESCE(customers.name, '') " + orderDir)
case "grand_total":
return db.Order("(SELECT COALESCE(SUM(mp.total_price), 0) FROM marketing_products mp WHERE mp.marketing_id = marketings.id) " + orderDir)
case "created_at":
return db.Order("marketings.created_at " + orderDir)
default:
return db.Order("created_at DESC").Order("updated_at DESC")
}
@@ -542,15 +540,9 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil, err
}
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status")
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil)
@@ -636,23 +628,6 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
}
}
if latestApproval != nil && latestApproval.StepNumber == uint16(utils.MarketingDeliveryOrder) {
action := entity.ApprovalActionUpdated
_, err := approvalSvcTx.CreateApproval(
c.Context(),
utils.ApprovalWorkflowMarketing,
id,
utils.MarketingStepSalesOrder,
&action,
actorID,
nil)
if err != nil {
if !errors.Is(err, gorm.ErrDuplicatedKey) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval to Sales Order")
}
}
}
return nil
})
if err != nil {
@@ -516,7 +516,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
c.Context(),
utils.ApprovalWorkflowMarketing,
id,
utils.MarketingStepPengajuan,
approvalutils.ApprovalStep(latestApproval.StepNumber),
&action,
actorID,
nil)
@@ -770,21 +770,15 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
var totalWeight, totalPrice float64
if rp.TotalPrice != nil {
totalWeight = math.Round(rp.Qty*rp.AvgWeight*100) / 100
totalPrice = *rp.TotalPrice
} else {
totalWeight, totalPrice = s.calculatePriceByMarketingType(
marketingType,
rp.Qty,
rp.AvgWeight,
rp.UnitPrice,
rp.Week,
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
}
totalWeight, totalPrice := s.calculatePriceByMarketingType(
marketingType,
rp.Qty,
rp.AvgWeight,
rp.UnitPrice,
rp.Week,
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId,
@@ -827,7 +821,7 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string,
totalPrice = math.Round(qty*unitPrice*100) / 100
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
totalWeight = math.Round(qty*avgWeight*100) / 100
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100
} else {
totalWeight = math.Round(qty*avgWeight*100) / 100
@@ -31,7 +31,7 @@ type DeliveryOrderQuery struct {
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
}
@@ -26,7 +26,6 @@ type CreateMarketingProduct struct {
UnitPrice float64 `json:"unit_price" validate:"required,gt=0"`
Qty float64 `json:"qty" validate:"required,gt=0"`
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gt=0"`
TotalPrice *float64 `json:"total_price" validate:"omitempty,gt=0"`
}
type Update struct {
@@ -14,7 +14,6 @@ type CustomerRelationDTO struct {
Name string `json:"name"`
Type string `json:"type"`
AccountNumber string `json:"account_number"`
BankName string `json:"bank_name"`
Address string `json:"address,omitempty"`
Balance float64 `json:"balance"`
Pic *userDTO.UserRelationDTO `json:"pic,omitempty"`
@@ -29,7 +28,6 @@ type CustomerListDTO struct {
Phone string `json:"phone"`
Email string `json:"email"`
AccountNumber string `json:"account_number"`
BankName string `json:"bank_name"`
Balance float64 `json:"balance"`
Pic userDTO.UserRelationDTO `json:"pic"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
@@ -55,7 +53,6 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO {
Name: e.Name,
Type: e.Type,
AccountNumber: e.AccountNumber,
BankName: e.BankName,
Address: e.Address,
Balance: e.Balance,
Pic: pic,
@@ -84,7 +81,6 @@ func ToCustomerListDTO(e entity.Customer) CustomerListDTO {
Phone: e.Phone,
Email: e.Email,
AccountNumber: e.AccountNumber,
BankName: e.BankName,
Pic: pic,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
@@ -133,7 +133,6 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
Phone: req.Phone,
Email: req.Email,
AccountNumber: req.AccountNumber,
BankName: req.BankName,
CreatedBy: actorID,
}
@@ -194,10 +193,6 @@ func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
updateBody["account_number"] = *req.AccountNumber
}
if req.BankName != nil {
updateBody["bank_name"] = *req.BankName
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
@@ -8,7 +8,6 @@ type Create struct {
Phone string `json:"phone" validate:"required_strict,max=20"`
Email string `json:"email" validate:"required_strict,email,max=50"`
AccountNumber string `json:"account_number" validate:"required_strict,max=50"`
BankName string `json:"bank_name" validate:"required_strict,max=100"`
}
type Update struct {
@@ -19,7 +18,6 @@ type Update struct {
Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"`
Email *string `json:"email,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
}
type Query struct {
@@ -26,7 +26,6 @@ type SupplierListDTO struct {
Address string `json:"address"`
Npwp *string `json:"npwp,omitempty"`
AccountNumber *string `json:"account_number,omitempty"`
BankName *string `json:"bank_name,omitempty"`
Balance float64 `json:"balance"`
DueDate int `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
@@ -67,7 +66,6 @@ func ToSupplierListDTO(e entity.Supplier) SupplierListDTO {
Address: e.Address,
Npwp: e.Npwp,
AccountNumber: e.AccountNumber,
BankName: e.BankName,
Balance: e.Balance,
DueDate: e.DueDate,
SupplierRelationDTO: ToSupplierRelationDTO(e),
@@ -160,7 +160,6 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
Address: req.Address,
Npwp: req.Npwp,
AccountNumber: req.AccountNumber,
BankName: req.BankName,
DueDate: req.DueDate,
CreatedBy: actorID,
}
@@ -244,10 +243,6 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
updateBody["account_number"] = *req.AccountNumber
}
if req.BankName != nil {
updateBody["bank_name"] = *req.BankName
}
if req.DueDate != nil {
updateBody["due_date"] = *req.DueDate
}
@@ -12,7 +12,6 @@ type Create struct {
Address string `json:"address" validate:"required_strict"`
Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
DueDate int `json:"due_date" validate:"required_strict,number,gt=0"`
}
@@ -28,7 +27,6 @@ type Update struct {
Address *string `json:"address,omitempty" validate:"omitempty"`
Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
DueDate *int `json:"due_date,omitempty" validate:"omitempty,number,gt=0"`
}
@@ -91,17 +91,17 @@ func buildPurchaseQuery(c *fiber.Ctx) *validation.Query {
Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
StartDate: strings.TrimSpace(c.Query("start_date")),
EndDate: strings.TrimSpace(c.Query("end_date")),
FilterBy: strings.TrimSpace(c.Query("filter_by")),
PoDate: strings.TrimSpace(c.Query("po_date")),
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")),
PoDateTo: strings.TrimSpace(c.Query("po_date_to")),
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")),
SortBy: strings.TrimSpace(c.Query("sort_by", "")),
SortOrder: strings.TrimSpace(c.Query("sort_order", "")),
}
}
@@ -2,6 +2,7 @@ package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -42,13 +43,15 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) {
}
}
grandTotals := buildPurchaseGrandTotalMap(purchases)
if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil {
return nil, err
}
if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil {
return nil, err
}
if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases); err != nil {
if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases, grandTotals); err != nil {
return nil, err
}
if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{
@@ -77,17 +80,9 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
"F": 22,
"G": 22,
"H": 32,
"I": 10,
"J": 12,
"K": 16,
"L": 16,
"M": 22,
"N": 12,
"O": 16,
"P": 16,
"Q": 18,
"R": 18,
"S": 24,
"I": 18,
"J": 18,
"K": 24,
}
for col, width := range columnWidths {
@@ -104,25 +99,17 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"PR Number", // A
"PO Number", // B
"Tanggal PO", // C
"Tanggal Terima", // D
"Supplier", // E
"Lokasi", // F
"Gudang", // G
"Product", // H
"Qty", // I
"Satuan", // J
"Price", // K
"Total Produk", // L
"Vendor Ekspedisi",// M
"Qty Ekspedisi", // N
"Price Ekspedisi", // O
"Total Ekspedisi", // P
"Grand Total All", // Q
"Status", // R
"Notes", // S
"PR Number",
"PO Number",
"Tanggal PO",
"Tanggal Terima",
"Supplier",
"Lokasi",
"Gudang",
"Product",
"Status",
"Grand Total",
"Notes",
}
for i, header := range headers {
@@ -150,36 +137,34 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
return err
}
return file.SetCellStyle(sheet, "A1", "S1", headerStyle)
return file.SetCellStyle(sheet, "A1", "K1", headerStyle)
}
func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase) error {
func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase, grandTotals map[uint]float64) error {
if len(purchases) == 0 {
return nil
}
var sumL, sumP, sumQ float64
rowIdx := 2
for p := range purchases {
purchase := &purchases[p]
total := grandTotals[purchase.Id]
if len(purchase.Items) == 0 {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, &sumL, &sumP, &sumQ); err != nil {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, total); err != nil {
return err
}
rowIdx++
continue
}
for it := range purchase.Items {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], &sumL, &sumP, &sumQ); err != nil {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], total); err != nil {
return err
}
rowIdx++
}
}
lastDataRow := rowIdx - 1
lastRow := rowIdx - 1
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
@@ -196,7 +181,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A2", "S"+strconv.Itoa(lastDataRow), dataStyle); err != nil {
if err := file.SetCellStyle(sheet, "A2", "K"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err
}
@@ -215,17 +200,14 @@ func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "K2", "Q"+strconv.Itoa(lastDataRow), moneyStyle); err != nil {
return err
}
return addPurchaseExportSumRow(file, sheet, rowIdx, sumL, sumP, sumQ)
return file.SetCellStyle(sheet, "J2", "J"+strconv.Itoa(lastRow), moneyStyle)
}
func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, sumL, sumP, sumQ *float64) error {
func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, grandTotal float64) error {
row := strconv.Itoa(rowIdx)
// Purchase-level columns (repeat for every item row of the same purchase)
// Purchase-level columns (repeat across rows of the same purchase)
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil {
return err
}
@@ -238,40 +220,26 @@ func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purch
if err := file.SetCellValue(sheet, "E"+row, safePurchaseExportEntitySupplierName(purchase)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "R"+row, formatPurchaseExportEntityStatus(purchase)); err != nil {
if err := file.SetCellValue(sheet, "I"+row, formatPurchaseExportEntityStatus(purchase)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "S"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
if err := file.SetCellValue(sheet, "J"+row, formatPurchaseRupiah(grandTotal)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
return err
}
// Item-level columns
if item == nil {
for _, col := range []string{"D", "F", "G", "H", "J", "M"} {
for _, col := range []string{"D", "F", "G", "H"} {
if err := file.SetCellValue(sheet, col+row, "-"); err != nil {
return err
}
}
for _, col := range []string{"I", "K", "L", "N", "O", "P", "Q"} {
if err := file.SetCellValue(sheet, col+row, 0); err != nil {
return err
}
}
return nil
}
// Item-level columns
var expeditionQty, expeditionPrice, expeditionTotal float64
if item.ExpenseNonstock != nil {
expeditionQty = item.ExpenseNonstock.Qty
expeditionPrice = item.ExpenseNonstock.Price
expeditionTotal = expeditionQty * expeditionPrice
}
itemGrandTotal := item.TotalPrice + expeditionTotal
*sumL += item.TotalPrice
*sumP += expeditionTotal
*sumQ += itemGrandTotal
if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil {
return err
}
@@ -284,96 +252,20 @@ func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purch
if err := file.SetCellValue(sheet, "H"+row, safePurchaseItemProductName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+row, item.TotalQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, safePurchaseItemUomName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+row, item.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, item.TotalPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+row, safePurchaseItemExpeditionVendorName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+row, expeditionQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+row, expeditionPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, expeditionTotal); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+row, itemGrandTotal); err != nil {
return err
}
return nil
}
func addPurchaseExportSumRow(file *excelize.File, sheet string, rowIdx int, sumL, sumP, sumQ float64) error {
row := strconv.Itoa(rowIdx)
sumStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 {
result := make(map[uint]float64, len(items))
for i := range items {
total := 0.0
for j := range items[i].Items {
total += items[i].Items[j].TotalPrice
}
result[items[i].Id] = total
}
sumMoneyStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+row, "S"+row, sumStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "L"+row, "L"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "P"+row, "Q"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellValue(sheet, "A"+row, "TOTAL"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, sumL); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, sumP); err != nil {
return err
}
return file.SetCellValue(sheet, "Q"+row, sumQ)
return result
}
func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string {
@@ -404,24 +296,6 @@ func safePurchaseItemProductName(item *entity.PurchaseItem) string {
return safePurchaseExportText(item.Product.Name)
}
func safePurchaseItemUomName(item *entity.PurchaseItem) string {
if item.Product == nil || item.Product.Uom.Id == 0 {
return "-"
}
return safePurchaseExportText(item.Product.Uom.Name)
}
func safePurchaseItemExpeditionVendorName(item *entity.PurchaseItem) string {
if item.ExpenseNonstock == nil || item.ExpenseNonstock.Expense == nil {
return "-"
}
exp := item.ExpenseNonstock.Expense
if exp.Supplier == nil || exp.Supplier.Id == 0 {
return "-"
}
return safePurchaseExportText(exp.Supplier.Name)
}
func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string {
if purchase.LatestApproval == nil {
return "-"
@@ -464,3 +338,37 @@ func safePurchaseExportText(value string) string {
return trimmed
}
func formatPurchaseRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
@@ -22,8 +22,9 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
nil,
"catatan",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(11, "Pakan Starter", 500, 2, 1000000, "Location A", "kg"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350, 1, 350000, "Location B", "botol"),
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"),
buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""),
},
),
buildPurchaseForExportTest(
@@ -36,7 +37,7 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
ptrApprovalAction(entity.ApprovalActionRejected),
"",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(21, "Obat X", 75000, 1, 75000, "", ""),
buildPurchaseItemForExportTest(21, "Obat X", 75000, ""),
},
),
})
@@ -50,27 +51,16 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
}
defer file.Close()
// Verify all 19 headers
expectedHeaders := map[string]string{
"A1": "PR Number",
"B1": "PO Number",
"C1": "Tanggal PO",
"D1": "Tanggal Terima",
"E1": "Supplier",
"F1": "Lokasi",
"G1": "Gudang",
"H1": "Product",
"I1": "Qty",
"J1": "Satuan",
"K1": "Price",
"L1": "Total Produk",
"M1": "Vendor Ekspedisi",
"N1": "Qty Ekspedisi",
"O1": "Price Ekspedisi",
"P1": "Total Ekspedisi",
"Q1": "Grand Total All",
"R1": "Status",
"S1": "Notes",
"D1": "Supplier",
"E1": "Lokasi",
"F1": "Status",
"G1": "Grand Total",
"H1": "Products",
"I1": "Notes",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(purchaseExportSheetName, cell)
@@ -82,46 +72,24 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
}
}
// Row 2: Purchase 1, Item 1 (Pakan Starter)
assertPurchaseCellEquals(t, file, "A2", "PR-00011")
assertPurchaseCellEquals(t, file, "B2", "PO-00011")
assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
assertPurchaseCellEquals(t, file, "E2", "Supplier A")
assertPurchaseCellEquals(t, file, "F2", "Location A")
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter")
assertPurchaseCellEquals(t, file, "J2", "kg")
assertPurchaseCellEquals(t, file, "K2", "500")
assertPurchaseCellEquals(t, file, "L2", "1000000")
assertPurchaseCellEquals(t, file, "M2", "-")
assertPurchaseCellEquals(t, file, "P2", "0")
assertPurchaseCellEquals(t, file, "Q2", "1000000")
assertPurchaseCellEquals(t, file, "R2", "Manager Purchase")
assertPurchaseCellEquals(t, file, "S2", "catatan")
assertPurchaseCellEquals(t, file, "D2", "Supplier A")
assertPurchaseCellEquals(t, file, "E2", "Location A")
assertPurchaseCellEquals(t, file, "F2", "Manager Purchase")
assertPurchaseCellEquals(t, file, "G2", "Rp 1.350.000")
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter, Vitamin A")
assertPurchaseCellEquals(t, file, "I2", "catatan")
// Row 3: Purchase 1, Item 2 (Vitamin A)
assertPurchaseCellEquals(t, file, "A3", "PR-00011")
assertPurchaseCellEquals(t, file, "H3", "Vitamin A")
assertPurchaseCellEquals(t, file, "J3", "botol")
assertPurchaseCellEquals(t, file, "L3", "350000")
assertPurchaseCellEquals(t, file, "Q3", "350000")
// Row 4: Purchase 2, Item 1 (Obat X) — no location, rejected
assertPurchaseCellEquals(t, file, "A4", "PR-00012")
assertPurchaseCellEquals(t, file, "B4", "-")
assertPurchaseCellEquals(t, file, "C4", "-")
assertPurchaseCellEquals(t, file, "F4", "-")
assertPurchaseCellEquals(t, file, "H4", "Obat X")
assertPurchaseCellEquals(t, file, "J4", "-")
assertPurchaseCellEquals(t, file, "L4", "75000")
assertPurchaseCellEquals(t, file, "Q4", "75000")
assertPurchaseCellEquals(t, file, "R4", "Ditolak")
assertPurchaseCellEquals(t, file, "S4", "-")
// Row 5: SUM row — total produk=1425000, ekspedisi=0, grand total all=1425000
assertPurchaseCellEquals(t, file, "A5", "TOTAL")
assertPurchaseCellEquals(t, file, "L5", "1425000")
assertPurchaseCellEquals(t, file, "P5", "0")
assertPurchaseCellEquals(t, file, "Q5", "1425000")
assertPurchaseCellEquals(t, file, "A3", "PR-00012")
assertPurchaseCellEquals(t, file, "B3", "-")
assertPurchaseCellEquals(t, file, "C3", "-")
assertPurchaseCellEquals(t, file, "E3", "-")
assertPurchaseCellEquals(t, file, "F3", "Ditolak")
assertPurchaseCellEquals(t, file, "G3", "Rp 75.000")
assertPurchaseCellEquals(t, file, "H3", "Obat X")
assertPurchaseCellEquals(t, file, "I3", "-")
}
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
@@ -176,20 +144,13 @@ func buildPurchaseForExportTest(
}
}
func buildPurchaseItemForExportTest(productID uint, productName string, price, totalQty, totalPrice float64, locationName, uomName string) entity.PurchaseItem {
uomID := uint(0)
if uomName != "" {
uomID = productID + 2000
}
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64, locationName string) entity.PurchaseItem {
item := entity.PurchaseItem{
ProductId: productID,
Price: price,
TotalQty: totalQty,
TotalPrice: totalPrice,
Product: &entity.Product{
Id: productID,
Name: productName,
Uom: entity.Uom{Id: uomID, Name: uomName},
},
}
+10 -26
View File
@@ -32,15 +32,12 @@ type PurchaseListDTO struct {
RequesterName string `json:"requester_name"`
PoExpedition []PoExpeditionDTO `json:"po_expedition"`
Items []PurchaseItemDTO `json:"items"`
Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
ProductsTotal float64 `json:"products_total"`
ExpeditionTotal float64 `json:"expedition_total"`
GrandTotalAll float64 `json:"grand_total_all"`
Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
}
type PurchaseDetailDTO struct {
@@ -72,8 +69,6 @@ type PurchaseItemDTO struct {
VehicleNumber *string `json:"vehicle_number"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"`
ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"`
ExpeditionQty float64 `json:"expedition_qty"`
ExpeditionTotal float64 `json:"expedition_total"`
HasChickin bool `json:"has_chickin"`
}
@@ -132,8 +127,6 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO {
if item.ExpenseNonstock != nil {
priceCopy := item.ExpenseNonstock.Price
dto.TransportPerItem = &priceCopy
dto.ExpeditionQty = item.ExpenseNonstock.Qty
dto.ExpeditionTotal = item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
if item.ExpenseNonstock.Expense != nil {
exp := item.ExpenseNonstock.Expense
@@ -180,21 +173,15 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
}
var (
poExpedition = make([]PoExpeditionDTO, 0)
location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO
receivedDate *time.Time
productsTotal float64
expeditionTotal float64
poExpedition = make([]PoExpeditionDTO, 0)
location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO
receivedDate *time.Time
)
productMap := make(map[uint]productDTO.ProductRelationDTO)
expeditionRefSet := make(map[uint64]struct{})
for i := range p.Items {
item := p.Items[i]
productsTotal += item.TotalPrice
if item.ExpenseNonstock != nil {
expeditionTotal += item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
}
if item.Product != nil && item.Product.Id != 0 {
if _, exists := productMap[item.Product.Id]; !exists {
productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product)
@@ -248,9 +235,6 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
LatestApproval: latestApproval,
ProductsTotal: productsTotal,
ExpeditionTotal: expeditionTotal,
GrandTotalAll: productsTotal + expeditionTotal,
}
}
@@ -145,16 +145,33 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
offset := (params.Page - 1) * params.Limit
createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo)
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id")
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
dateStart, dateEnd, err := parsePurchaseDateRangeForQuery(params.StartDate, params.EndDate, "date")
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
var poDateStart *time.Time
var poDateEnd *time.Time
if strings.TrimSpace(params.PoDate) != "" {
poDate, parseErr := utils.ParseDateString(strings.TrimSpace(params.PoDate))
if parseErr != nil {
return nil, 0, utils.BadRequest("po_date must use format YYYY-MM-DD")
}
poDateStart = &poDate
poDateEndValue := poDate.AddDate(0, 0, 1)
poDateEnd = &poDateEndValue
} else {
poDateStart, poDateEnd, err = parsePoDateRangeForQuery(params.PoDateFrom, params.PoDateTo)
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
}
filterBy := strings.TrimSpace(params.FilterBy)
search := strings.ToLower(strings.TrimSpace(params.Search))
approvalStatuses := parseStringCSVFilter(params.ApprovalStatus)
@@ -170,41 +187,23 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = db.Where("supplier_id = ?", params.SupplierID)
}
switch filterBy {
case "po_date":
if dateStart != nil {
db = db.Where("purchases.po_date >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where("purchases.po_date < ?", *dateEnd)
}
case "due_date":
if dateStart != nil {
db = db.Where("purchases.due_date >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where("purchases.due_date < ?", *dateEnd)
}
case "received_date":
if dateStart != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date >= ?)`,
*dateStart,
)
}
if dateEnd != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date < ?)`,
*dateEnd,
)
}
default:
if dateStart != nil {
db = db.Where("purchases.created_at >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where("purchases.created_at < ?", *dateEnd)
}
if createdFrom != nil {
db = db.Where("created_at >= ?", *createdFrom)
}
if createdTo != nil {
db = db.Where("created_at < ?", *createdTo)
}
if poDateStart != nil {
db = db.Where("purchases.po_date >= ?", *poDateStart)
}
if poDateStart != nil {
db = db.Where("purchases.po_date >= ?", *poDateStart)
}
if poDateEnd != nil {
db = db.Where("purchases.po_date < ?", *poDateEnd)
}
if scope.Restrict {
@@ -262,48 +261,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
db = applyPurchaseSearchFilter(db, search)
sortBy := strings.TrimSpace(params.SortBy)
sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder))
if sortOrder == "" {
sortOrder = "DESC"
}
switch sortBy {
case "po_expedition":
return db.Order(`(SELECT MIN(e.reference_number) FROM purchase_items pi
LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
LEFT JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder + " NULLS LAST")
case "supplier":
return db.Order(`(SELECT COALESCE(s.name, '') FROM suppliers s WHERE s.id = purchases.supplier_id) ` + sortOrder)
case "requester_name":
return db.Order(`(SELECT COALESCE(u.name, '') FROM users u WHERE u.id = purchases.created_by) ` + sortOrder)
case "products":
return db.Order(`(SELECT MIN(COALESCE(p.name, '')) FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder)
case "location":
return db.Order(`(SELECT MIN(COALESCE(l.name, '')) FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
JOIN locations l ON l.id = w.location_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder)
case "po_date":
return db.Order("purchases.po_date " + sortOrder)
case "po_number":
return db.Order("COALESCE(purchases.po_number, purchases.pr_number) " + sortOrder)
case "received_date":
return db.Order(`(SELECT MIN(pi2.received_date) FROM purchase_items pi2 WHERE pi2.purchase_id = purchases.id) ` + sortOrder)
case "due_date":
return db.Order("purchases.due_date " + sortOrder)
case "status":
return db.Order(`(SELECT COALESCE(a.step_name, '') FROM approvals a
WHERE a.approvable_type = 'PURCHASES' AND a.approvable_id = purchases.id
ORDER BY a.action_at DESC, a.id DESC LIMIT 1) ` + sortOrder)
case "created_at":
return db.Order("purchases.created_at " + sortOrder)
default:
return db.Order("created_at DESC").Order("purchases.id DESC")
}
return db.Order("created_at DESC").Order("purchases.id DESC")
})
if err != nil {
@@ -2239,29 +2197,30 @@ func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []ent
return nil
}
func parsePurchaseDateRangeForQuery(fromStr, toStr, fieldName string) (*time.Time, *time.Time, error) {
func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) {
var fromPtr *time.Time
var toPtr *time.Time
if strings.TrimSpace(fromStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr))
if err != nil {
return nil, nil, errors.New(fieldName + "_from must use format YYYY-MM-DD")
return nil, nil, errors.New("po_date_from must use format YYYY-MM-DD")
}
fromPtr = &parsed
fromValue := parsed
fromPtr = &fromValue
}
if strings.TrimSpace(toStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(toStr))
if err != nil {
return nil, nil, errors.New(fieldName + "_to must use format YYYY-MM-DD")
return nil, nil, errors.New("po_date_to must use format YYYY-MM-DD")
}
nextDay := parsed.AddDate(0, 0, 1)
toPtr = &nextDay
}
if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) {
return nil, nil, errors.New(fieldName + "_from must be earlier than " + fieldName + "_to")
return nil, nil, errors.New("po_date_from must be earlier than po_date_to")
}
return fromPtr, toPtr, nil
@@ -75,10 +75,10 @@ type Query struct {
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=po_date due_date received_date created_at"`
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"`
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"`
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"`
Search string `query:"search" validate:"omitempty,max=100"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=po_expedition supplier requester_name products location po_date received_date due_date status created_at po_number"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"`
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"`
}
@@ -51,8 +51,6 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
AreaId: int64(ctx.QueryInt("area_id", 0)),
LocationId: int64(ctx.QueryInt("location_id", 0)),
RealizationDate: ctx.Query("realization_date", ""),
SortBy: ctx.Query("sort_by", ""),
SortOrder: ctx.Query("sort_order", ""),
}
locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB())
@@ -364,7 +362,6 @@ func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error {
StartDate: ctx.Query("start_date", ""),
EndDate: ctx.Query("end_date", ""),
FilterBy: ctx.Query("filter_by", ""),
SortBy: ctx.Query("sort_by", ""),
SortOrder: ctx.Query("sort_order", ""),
}
@@ -392,13 +389,6 @@ func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error {
return err
}
if isDebtSupplierExcelExportRequest(ctx) {
return exportDebtSupplierExcel(ctx, result)
}
if isDebtSupplierExcelAllExportRequest(ctx) {
return exportDebtSupplierExcelAll(ctx, result)
}
supplierIDs = query.SupplierIDs
if supplierIDs == nil {
supplierIDs = []int64{}
@@ -469,8 +459,6 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
Limit: ctx.QueryInt("limit", 10),
CustomerIDs: customerIDs,
FilterBy: strings.ToUpper(ctx.Query("filter_by", "")),
SortBy: ctx.Query("sort_by", ""),
SortOrder: ctx.Query("sort_order", ""),
StartDate: ctx.Query("start_date", ""),
EndDate: ctx.Query("end_date", ""),
}
@@ -485,13 +473,6 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
return err
}
if isCustomerPaymentExcelExportRequest(ctx) {
return exportCustomerPaymentExcel(ctx, result)
}
if isCustomerPaymentExcelAllExportRequest(ctx) {
return exportCustomerPaymentExcelAll(ctx, result)
}
// If single customer mode (only 1 customer ID), return without pagination
if len(customerIDs) == 1 {
return ctx.Status(fiber.StatusOK).
@@ -519,83 +500,6 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
})
}
type BalanceMonitoringResponse struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Meta response.Meta `json:"meta"`
Data []dto.BalanceMonitoringRowDTO `json:"data"`
Totals dto.BalanceMonitoringTotalsDTO `json:"totals"`
}
func (c *RepportController) GetBalanceMonitoring(ctx *fiber.Ctx) error {
customerIDs, err := parseUintCSV(ctx.Query("customer_ids"))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "customer_ids must be comma separated positive integers")
}
salesIDs, err := parseUintCSV(ctx.Query("sales_ids"))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "sales_ids must be comma separated positive integers")
}
query := &validation.BalanceMonitoringQuery{
Page: ctx.QueryInt("page", 1),
Limit: ctx.QueryInt("limit", 10),
CustomerIDs: customerIDs,
SalesIDs: salesIDs,
FilterBy: strings.ToLower(ctx.Query("filter_by", "")),
SortBy: ctx.Query("sort_by", ""),
SortOrder: ctx.Query("sort_order", ""),
StartDate: ctx.Query("start_date", ""),
EndDate: ctx.Query("end_date", ""),
}
result, totals, totalResults, err := c.RepportService.GetBalanceMonitoring(ctx, query)
if err != nil {
return err
}
limit := query.Limit
if limit < 1 {
limit = 10
}
return ctx.Status(fiber.StatusOK).JSON(BalanceMonitoringResponse{
Code: fiber.StatusOK,
Status: "success",
Message: "Get balance monitoring report successfully",
Meta: response.Meta{
Page: query.Page,
Limit: limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(limit))),
TotalResults: totalResults,
},
Data: result,
Totals: totals,
})
}
func parseUintCSV(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
result := make([]uint, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseUint(part, 10, 32)
if err != nil || id == 0 {
return nil, fmt.Errorf("invalid id: %s", part)
}
result = append(result, uint(id))
}
return result, nil
}
func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error {
idParam := ctx.Params("idProjectFlockKandang")
if idParam == "" {
@@ -1,576 +0,0 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
)
func isCustomerPaymentExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func isCustomerPaymentExcelAllExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all")
}
func exportCustomerPaymentExcel(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error {
content, err := buildCustomerPaymentWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func exportCustomerPaymentExcelAll(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error {
content, err := buildCustomerPaymentAllWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-all-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func buildCustomerPaymentWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if len(items) == 0 {
if err := writeCustomerPaymentSheet(file, defaultSheet, dto.CustomerPaymentReportItem{}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
for idx, item := range items {
sheetName := sanitizeCustomerPaymentSheetName(customerPaymentName(item))
if sheetName == "" {
sheetName = fmt.Sprintf("Customer %d", idx+1)
}
if idx == 0 {
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
} else {
if _, err := file.NewSheet(sheetName); err != nil {
return nil, err
}
}
if err := writeCustomerPaymentSheet(file, sheetName, item); err != nil {
return nil, err
}
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func buildCustomerPaymentAllWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
const sheet = "Kontrol Pembayaran Customer"
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != sheet {
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
return nil, err
}
}
if err := setCustomerPaymentAllColumns(file, sheet); err != nil {
return nil, err
}
if err := setCustomerPaymentAllHeaders(file, sheet); err != nil {
return nil, err
}
if err := writeCustomerPaymentAllRows(file, sheet, items); err != nil {
return nil, err
}
if err := file.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
var cpSheetHeaders = []string{
"No",
"Tanggal DO/Bayar",
"Tanggal Realisasi",
"Aging",
"Referensi",
"Nomor Polisi",
"Ekor/Qty",
"Berat (Kg)",
"AVG",
"Harga/Unit (Rp)",
"Harga Akhir (Rp)",
"Total (Rp)",
"Pembayaran (Rp)",
"Saldo Piutang (Rp)",
"Keterangan",
"Pengambilan",
"Sales/Marketing",
}
var cpAllSheetHeaders = append([]string{"Customer"}, cpSheetHeaders...)
var cpSheetColumnWidths = map[string]float64{
"A": 5,
"B": 15,
"C": 12,
"D": 8,
"E": 12,
"F": 15,
"G": 10,
"H": 12,
"I": 10,
"J": 15,
"K": 15,
"L": 15,
"M": 15,
"N": 15,
"O": 20,
"P": 15,
"Q": 20,
}
var cpAllSheetColumnWidths = map[string]float64{
"A": 22,
"B": 6,
"C": 15,
"D": 15,
"E": 8,
"F": 12,
"G": 15,
"H": 10,
"I": 12,
"J": 10,
"K": 15,
"L": 15,
"M": 15,
"N": 15,
"O": 15,
"P": 20,
"Q": 15,
"R": 20,
}
func writeCustomerPaymentSheet(file *excelize.File, sheet string, item dto.CustomerPaymentReportItem) error {
for col, width := range cpSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
// Row 1: headers
for i, h := range cpSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
redStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "FF0000"},
})
if err != nil {
return err
}
// Row 2: saldo awal
if err := file.SetCellValue(sheet, "N2", item.InitialBalance); err != nil {
return err
}
if item.InitialBalance < 0 {
if err := file.SetCellStyle(sheet, "N2", "N2", redStyle); err != nil {
return err
}
}
// Rows 3+: data rows
for i, row := range item.Rows {
rowNum := i + 3
rowStr := fmt.Sprintf("%d", rowNum)
cells := customerPaymentRowCells(row, i+1)
for colIdx, val := range cells {
col, _ := excelize.ColumnNumberToName(colIdx + 1)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if row.AccountsReceivable < 0 {
if err := file.SetCellStyle(sheet, "N"+rowStr, "N"+rowStr, redStyle); err != nil {
return err
}
}
}
// Total row
totalRowNum := len(item.Rows) + 3
totalRowStr := fmt.Sprintf("%d", totalRowNum)
totalCells := map[string]interface{}{
"A": "Total",
"G": formatCPIDInteger(item.Summary.TotalQty),
"H": formatCPIDInteger(item.Summary.TotalWeight),
"K": item.Summary.TotalFinalAmount,
"L": item.Summary.TotalGrandAmount,
"M": item.Summary.TotalPayment,
"N": item.Summary.TotalAccountsReceivable,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
if item.Summary.TotalAccountsReceivable < 0 {
if err := file.SetCellStyle(sheet, "N"+totalRowStr, "N"+totalRowStr, redStyle); err != nil {
return err
}
}
return nil
}
func setCustomerPaymentAllColumns(file *excelize.File, sheet string) error {
for col, width := range cpAllSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setCustomerPaymentAllHeaders(file *excelize.File, sheet string) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
Border: borderStyle,
})
if err != nil {
return err
}
for i, h := range cpAllSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
lastCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders))
return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle)
}
func writeCustomerPaymentAllRows(file *excelize.File, sheet string, items []dto.CustomerPaymentReportItem) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
dataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
totalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
redDataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "FF0000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
redTotalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FF0000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
lastHeaderCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders))
currentRow := 2
for _, item := range items {
name := customerPaymentName(item)
// Saldo awal row
saldoStr := fmt.Sprintf("%d", currentRow)
if err := file.SetCellValue(sheet, "A"+saldoStr, name); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+saldoStr, item.InitialBalance); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+saldoStr, lastHeaderCol+saldoStr, dataStyle); err != nil {
return err
}
if item.InitialBalance < 0 {
if err := file.SetCellStyle(sheet, "O"+saldoStr, "O"+saldoStr, redDataStyle); err != nil {
return err
}
}
currentRow++
// Data rows
for seq, row := range item.Rows {
rowStr := fmt.Sprintf("%d", currentRow)
if err := file.SetCellValue(sheet, "A"+rowStr, name); err != nil {
return err
}
cells := customerPaymentRowCells(row, seq+1)
for colIdx, val := range cells {
col, _ := excelize.ColumnNumberToName(colIdx + 2)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+rowStr, lastHeaderCol+rowStr, dataStyle); err != nil {
return err
}
if row.AccountsReceivable < 0 {
if err := file.SetCellStyle(sheet, "O"+rowStr, "O"+rowStr, redDataStyle); err != nil {
return err
}
}
currentRow++
}
// Total row
totalStr := fmt.Sprintf("%d", currentRow)
totalCells := map[string]interface{}{
"A": name,
"B": "Total",
"H": formatCPIDInteger(item.Summary.TotalQty),
"I": formatCPIDInteger(item.Summary.TotalWeight),
"L": item.Summary.TotalFinalAmount,
"M": item.Summary.TotalGrandAmount,
"N": item.Summary.TotalPayment,
"O": item.Summary.TotalAccountsReceivable,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+totalStr, lastHeaderCol+totalStr, totalStyle); err != nil {
return err
}
if item.Summary.TotalAccountsReceivable < 0 {
if err := file.SetCellStyle(sheet, "O"+totalStr, "O"+totalStr, redTotalStyle); err != nil {
return err
}
}
currentRow++
// Empty separator row
currentRow++
}
return nil
}
// customerPaymentRowCells returns 17 cell values for cols A..Q.
func customerPaymentRowCells(row dto.CustomerPaymentReportRow, seq int) []interface{} {
return []interface{}{
seq,
formatCPDate(row.TransDate),
formatCPOptionalDate(row.DeliveryDate),
formatCPAging(row.AgingDay),
safeCPText(row.Reference),
joinCPStrings(row.VehicleNumbers),
formatCPIDInteger(row.Qty),
formatCPIDInteger(row.Weight),
formatCPAvg(row.AverageWeight),
row.UnitPrice,
row.FinalPrice,
row.TotalPrice,
row.PaymentAmount,
row.AccountsReceivable,
safeCPText(row.Status),
joinCPStrings(row.PickupInfo),
safeCPText(row.SalesPerson),
}
}
func customerPaymentName(item dto.CustomerPaymentReportItem) string {
name := strings.TrimSpace(item.Customer.Name)
if name == "" {
return "Customer"
}
return name
}
func sanitizeCustomerPaymentSheetName(name string) string {
replacer := strings.NewReplacer(
":", " ", "\\", " ", "/", " ",
"?", " ", "*", " ", "[", " ", "]", " ",
)
sanitized := strings.TrimSpace(replacer.Replace(name))
if sanitized == "" {
return "Sheet"
}
runes := []rune(sanitized)
if len(runes) > 31 {
return string(runes[:31])
}
return sanitized
}
var cpIndonesianMonths = [12]string{
"Jan", "Feb", "Mar", "Apr", "Mei", "Jun",
"Jul", "Agu", "Sep", "Okt", "Nov", "Des",
}
func formatCPDate(t time.Time) string {
if t.IsZero() {
return "-"
}
loc, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(loc)
}
return fmt.Sprintf("%02d %s %d", t.Day(), cpIndonesianMonths[t.Month()-1], t.Year())
}
func formatCPOptionalDate(t *time.Time) string {
if t == nil || t.IsZero() {
return "-"
}
return formatCPDate(*t)
}
func formatCPAging(v *int) string {
if v == nil {
return "-"
}
return strconv.Itoa(*v)
}
func formatCPIDInteger(v float64) string {
n := int64(math.Round(v))
if n == 0 {
return "0"
}
negative := n < 0
abs := n
if negative {
abs = -n
}
s := strconv.FormatInt(abs, 10)
// insert dots as thousand separators
var b strings.Builder
start := len(s) % 3
if start == 0 {
start = 3
}
b.WriteString(s[:start])
for i := start; i < len(s); i += 3 {
b.WriteByte('.')
b.WriteString(s[i : i+3])
}
if negative {
return "-" + b.String()
}
return b.String()
}
func formatCPAvg(v float64) string {
if v == 0 {
return "0"
}
s := strconv.FormatFloat(v, 'f', 2, 64)
return strings.ReplaceAll(s, ".", ",")
}
func safeCPText(s string) string {
t := strings.TrimSpace(s)
if t == "" {
return "-"
}
return t
}
func joinCPStrings(ss []string) string {
var parts []string
for _, s := range ss {
s = strings.TrimSpace(s)
if s != "" {
parts = append(parts, s)
}
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, "\n")
}
@@ -1,452 +0,0 @@
package controller
import (
"fmt"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
)
func isDebtSupplierExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func isDebtSupplierExcelAllExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all")
}
func exportDebtSupplierExcel(c *fiber.Ctx, items []dto.DebtSupplierDTO) error {
content, err := buildDebtSupplierWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-hutang-supplier-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func exportDebtSupplierExcelAll(c *fiber.Ctx, items []dto.DebtSupplierDTO) error {
content, err := buildDebtSupplierAllWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-hutang-supplier-all-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
// buildDebtSupplierWorkbook creates a workbook with one sheet per supplier.
func buildDebtSupplierWorkbook(items []dto.DebtSupplierDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if len(items) == 0 {
if err := writeDebtSupplierSheet(file, defaultSheet, dto.DebtSupplierDTO{}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
for idx, item := range items {
sheetName := sanitizeDebtSupplierSheetName(debtSupplierName(item))
if sheetName == "" {
sheetName = fmt.Sprintf("Supplier %d", idx+1)
}
if idx == 0 {
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
} else {
if _, err := file.NewSheet(sheetName); err != nil {
return nil, err
}
}
if err := writeDebtSupplierSheet(file, sheetName, item); err != nil {
return nil, err
}
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildDebtSupplierAllWorkbook creates a single-sheet workbook with purchase-supplier styling.
func buildDebtSupplierAllWorkbook(items []dto.DebtSupplierDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
const sheet = "Rekap Hutang Supplier"
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != sheet {
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
return nil, err
}
}
if err := setDebtSupplierAllColumns(file, sheet); err != nil {
return nil, err
}
if err := setDebtSupplierAllHeaders(file, sheet); err != nil {
return nil, err
}
if err := writeDebtSupplierAllRows(file, sheet, items); err != nil {
return nil, err
}
if err := file.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
var debtSupplierSheetHeaders = []string{
"No",
"Nomor PR",
"Nomor PO",
"Tanggal Terima/Bayar",
"Tanggal PO",
"Aging (Hari)",
"Area",
"Gudang",
"Jatuh Tempo",
"Status Jatuh Tempo",
"Nominal Pembelian (Rp)",
"Pembayaran (Rp)",
"Sisa Saldo Hutang (Rp)",
"Status",
"Nomor Perjalanan",
}
var debtSupplierAllSheetHeaders = append([]string{"Supplier"}, debtSupplierSheetHeaders...)
var debtSupplierSheetColumnWidths = map[string]float64{
"A": 5,
"B": 14,
"C": 12,
"D": 20,
"E": 10,
"F": 12,
"G": 15,
"H": 20,
"I": 12,
"J": 20,
"K": 20,
"L": 15,
"M": 20,
"N": 12,
"O": 15,
}
var debtSupplierAllSheetColumnWidths = map[string]float64{
"A": 24,
"B": 6,
"C": 14,
"D": 14,
"E": 20,
"F": 12,
"G": 10,
"H": 16,
"I": 22,
"J": 12,
"K": 22,
"L": 20,
"M": 18,
"N": 22,
"O": 14,
"P": 18,
}
func writeDebtSupplierSheet(file *excelize.File, sheet string, item dto.DebtSupplierDTO) error {
for col, width := range debtSupplierSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
// Row 1: headers
for i, h := range debtSupplierSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
// Row 2: saldo awal
if err := file.SetCellValue(sheet, "M2", item.InitialBalance); err != nil {
return err
}
// Rows 3+: data
redStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "FF0000"},
})
if err != nil {
return err
}
for i, row := range item.Rows {
rowNum := i + 3
rowStr := fmt.Sprintf("%d", rowNum)
values := debtSupplierRowCells(row, i+1)
for colIdx, val := range values {
col, _ := excelize.ColumnNumberToName(colIdx + 1)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if row.DebtPrice < 0 {
if err := file.SetCellStyle(sheet, "M"+rowStr, "M"+rowStr, redStyle); err != nil {
return err
}
}
}
// Total row
totalRowNum := len(item.Rows) + 3
totalRowStr := fmt.Sprintf("%d", totalRowNum)
totalCells := map[string]interface{}{
"A": "Total",
"F": item.Total.Aging,
"K": item.Total.TotalPrice,
"L": item.Total.PaymentPrice,
"M": item.Total.DebtPrice,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
if item.Total.DebtPrice < 0 {
if err := file.SetCellStyle(sheet, "M"+totalRowStr, "M"+totalRowStr, redStyle); err != nil {
return err
}
}
return nil
}
func setDebtSupplierAllColumns(file *excelize.File, sheet string) error {
for col, width := range debtSupplierAllSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return nil
}
func setDebtSupplierAllHeaders(file *excelize.File, sheet string) error {
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
Border: []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
},
})
if err != nil {
return err
}
for i, h := range debtSupplierAllSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
lastCol, _ := excelize.ColumnNumberToName(len(debtSupplierAllSheetHeaders))
return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle)
}
func writeDebtSupplierAllRows(file *excelize.File, sheet string, items []dto.DebtSupplierDTO) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
dataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
totalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
lastHeaderCol, _ := excelize.ColumnNumberToName(len(debtSupplierAllSheetHeaders))
currentRow := 2
for _, item := range items {
supplierName := debtSupplierName(item)
// Saldo awal row
saldoRowStr := fmt.Sprintf("%d", currentRow)
if err := file.SetCellValue(sheet, "A"+saldoRowStr, supplierName); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+saldoRowStr, item.InitialBalance); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+saldoRowStr, lastHeaderCol+saldoRowStr, dataStyle); err != nil {
return err
}
currentRow++
// Data rows
for seq, row := range item.Rows {
rowStr := fmt.Sprintf("%d", currentRow)
if err := file.SetCellValue(sheet, "A"+rowStr, supplierName); err != nil {
return err
}
values := debtSupplierRowCells(row, seq+1)
for colIdx, val := range values {
col, _ := excelize.ColumnNumberToName(colIdx + 2)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+rowStr, lastHeaderCol+rowStr, dataStyle); err != nil {
return err
}
currentRow++
}
// Total row
totalRowStr := fmt.Sprintf("%d", currentRow)
totalCells := map[string]interface{}{
"A": supplierName,
"B": "Total",
"L": item.Total.TotalPrice,
"M": item.Total.PaymentPrice,
"N": item.Total.DebtPrice,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+totalRowStr, lastHeaderCol+totalRowStr, totalStyle); err != nil {
return err
}
currentRow++
// Empty separator row
currentRow++
}
return nil
}
// debtSupplierRowCells returns cell values for one data row (columns: No, PR, PO, ReceivedDate, PoDate, Aging, Area, Warehouse, DueDate, DueStatus, TotalPrice, PaymentPrice, DebtPrice, Status, TravelNumber).
func debtSupplierRowCells(row dto.DebtSupplierRowDTO, seq int) []interface{} {
areaName := "-"
if row.Area != nil && strings.TrimSpace(row.Area.Name) != "" {
areaName = row.Area.Name
}
warehouseName := "-"
if row.Warehouse != nil && strings.TrimSpace(row.Warehouse.Name) != "" {
warehouseName = row.Warehouse.Name
}
return []interface{}{
seq,
safeDebtSupplierText(row.PrNumber),
safeDebtSupplierText(row.PoNumber),
safeDebtSupplierText(row.ReceivedDate),
safeDebtSupplierText(row.PoDate),
row.Aging,
areaName,
warehouseName,
safeDebtSupplierText(row.DueDate),
safeDebtSupplierText(row.DueStatus),
row.TotalPrice,
row.PaymentPrice,
row.DebtPrice,
safeDebtSupplierText(row.Status),
safeDebtSupplierText(row.TravelNumber),
}
}
func debtSupplierName(item dto.DebtSupplierDTO) string {
if item.Supplier != nil && strings.TrimSpace(item.Supplier.Name) != "" {
return item.Supplier.Name
}
return "Supplier"
}
func sanitizeDebtSupplierSheetName(name string) string {
replacer := strings.NewReplacer(
":", " ", "\\", " ", "/", " ",
"?", " ", "*", " ", "[", " ", "]", " ",
)
sanitized := strings.TrimSpace(replacer.Replace(name))
if sanitized == "" {
return "Sheet"
}
runes := []rune(sanitized)
if len(runes) > 31 {
return string(runes[:31])
}
return sanitized
}
func safeDebtSupplierText(s string) string {
t := strings.TrimSpace(s)
if t == "" {
return "-"
}
return t
}
@@ -2,6 +2,7 @@ package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -196,9 +197,9 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte
item.Qty,
item.AverageWeightKg,
item.TotalWeightKg,
item.SalesPricePerKg,
item.HppPricePerKg,
item.SalesAmount,
formatMarketingRupiah(item.SalesPricePerKg),
formatMarketingRupiah(item.HppPricePerKg),
formatMarketingRupiah(item.SalesAmount),
}
for colIdx, val := range values {
@@ -228,13 +229,13 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte
if err := file.SetCellValue(sheet, "N"+totalRow, summary.TotalWeightKg); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+totalRow, summary.AverageSalesPrice); err != nil {
if err := file.SetCellValue(sheet, "O"+totalRow, formatMarketingRupiah(summary.AverageSalesPrice)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+totalRow, summary.TotalHppPricePerKg); err != nil {
if err := file.SetCellValue(sheet, "P"+totalRow, formatMarketingRupiah(summary.TotalHppPricePerKg)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+totalRow, float64(summary.TotalSalesAmount)); err != nil {
if err := file.SetCellValue(sheet, "Q"+totalRow, formatMarketingRupiah(float64(summary.TotalSalesAmount))); err != nil {
return err
}
}
@@ -332,3 +333,30 @@ func safeMarketingExportText(value string) string {
return trimmed
}
// formatMarketingRupiah formats a float64 as Indonesian Rupiah string.
// e.g. 1000000 → "Rp 1.000.000"
func formatMarketingRupiah(value float64) string {
rounded := int64(math.Round(value))
negative := rounded < 0
abs := rounded
if negative {
abs = -rounded
}
numStr := strconv.FormatInt(abs, 10)
n := len(numStr)
var b strings.Builder
for i, c := range numStr {
if i > 0 && (n-i)%3 == 0 {
b.WriteByte('.')
}
b.WriteRune(c)
}
if negative {
return "Rp -" + b.String()
}
return "Rp " + b.String()
}
@@ -1,71 +0,0 @@
package dto
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
)
type BalanceMonitoringAyamDTO struct {
Ekor float64 `json:"ekor"`
Kg float64 `json:"kg"`
Nominal float64 `json:"nominal"`
}
type BalanceMonitoringTelurDTO struct {
Butir float64 `json:"butir"`
Kg float64 `json:"kg"`
Nominal float64 `json:"nominal"`
}
type BalanceMonitoringTradingDTO struct {
Qty float64 `json:"qty"`
Kg float64 `json:"kg"`
Nominal float64 `json:"nominal"`
}
type BalanceMonitoringRowDTO struct {
Customer customerDTO.CustomerRelationDTO `json:"customer"`
SaldoAwal float64 `json:"saldo_awal"`
PenjualanAyam BalanceMonitoringAyamDTO `json:"penjualan_ayam"`
PenjualanTelur BalanceMonitoringTelurDTO `json:"penjualan_telur"`
PenjualanTrading BalanceMonitoringTradingDTO `json:"penjualan_trading"`
Pembayaran float64 `json:"pembayaran"`
Aging int `json:"aging"`
AgingRataRata float64 `json:"aging_rata_rata"`
SaldoAkhir float64 `json:"saldo_akhir"`
}
type BalanceMonitoringTotalsDTO struct {
SaldoAwal float64 `json:"saldo_awal"`
PenjualanAyam BalanceMonitoringAyamDTO `json:"penjualan_ayam"`
PenjualanTelur BalanceMonitoringTelurDTO `json:"penjualan_telur"`
PenjualanTrading BalanceMonitoringTradingDTO `json:"penjualan_trading"`
Pembayaran float64 `json:"pembayaran"`
Aging int `json:"aging"`
AgingRataRata float64 `json:"aging_rata_rata"`
SaldoAkhir float64 `json:"saldo_akhir"`
}
func ToBalanceMonitoringRowDTO(
customer entity.Customer,
saldoAwal float64,
ayam BalanceMonitoringAyamDTO,
telur BalanceMonitoringTelurDTO,
trading BalanceMonitoringTradingDTO,
pembayaran float64,
aging int,
agingRataRata float64,
) BalanceMonitoringRowDTO {
saldoAkhir := saldoAwal + pembayaran - (ayam.Nominal + telur.Nominal + trading.Nominal)
return BalanceMonitoringRowDTO{
Customer: customerDTO.ToCustomerRelationDTO(customer),
SaldoAwal: saldoAwal,
PenjualanAyam: ayam,
PenjualanTelur: telur,
PenjualanTrading: trading,
Pembayaran: pembayaran,
Aging: aging,
AgingRataRata: agingRataRata,
SaldoAkhir: saldoAkhir,
}
}
@@ -6,7 +6,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
)
@@ -49,7 +48,6 @@ type RepportExpenseRealisasiDTO struct {
type RepportExpenseListDTO struct {
RepportExpenseBaseDTO
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
Pengajuan RepportExpensePengajuanDTO `json:"pengajuan"`
Realisasi RepportExpenseRealisasiDTO `json:"realisasi"`
@@ -135,15 +133,6 @@ func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNo
totalRealisasi = ns.Realization.Qty * ns.Realization.Price
}
var location *locationDTO.LocationRelationDTO
if ns.Expense != nil && ns.Expense.Location != nil && ns.Expense.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(*ns.Expense.Location)
location = &mapped
} else if ns.Kandang != nil && ns.Kandang.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(ns.Kandang.Location)
location = &mapped
}
// Get kandang data at the main level
var kandang *kandangDTO.KandangRelationDTO
if ns.Kandang != nil && ns.Kandang.Id != 0 {
@@ -153,7 +142,6 @@ func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNo
return RepportExpenseListDTO{
RepportExpenseBaseDTO: baseDTO,
Location: location,
Kandang: kandang,
Pengajuan: ToRepportExpensePengajuanDTO(ns),
Realisasi: realisasi,
-2
View File
@@ -40,7 +40,6 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db)
productionResultRepository := repportRepo.NewProductionResultRepository(db)
customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db)
balanceMonitoringRepository := repportRepo.NewBalanceMonitoringRepository(db)
customerRepository := customerRepo.NewCustomerRepository(db)
standardGrowthDetailRepository := productionStandardRepo.NewStandardGrowthDetailRepository(db)
productionStandardDetailRepository := productionStandardRepo.NewProductionStandardDetailRepository(db)
@@ -67,7 +66,6 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
hppPerKandangRepository,
productionResultRepository,
customerPaymentRepository,
balanceMonitoringRepository,
customerRepository,
standardGrowthDetailRepository,
productionStandardDetailRepository,
@@ -1,518 +0,0 @@
package repositories
import (
"context"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
type BalanceMonitoringCategoryRow struct {
CustomerID uint `gorm:"column:customer_id"`
AyamQty float64 `gorm:"column:ayam_qty"`
AyamKg float64 `gorm:"column:ayam_kg"`
AyamNominal float64 `gorm:"column:ayam_nominal"`
TelurQty float64 `gorm:"column:telur_qty"`
TelurKg float64 `gorm:"column:telur_kg"`
TelurNominal float64 `gorm:"column:telur_nominal"`
TradingQty float64 `gorm:"column:trading_qty"`
TradingKg float64 `gorm:"column:trading_kg"`
TradingNominal float64 `gorm:"column:trading_nominal"`
}
type BalanceMonitoringAgingRow struct {
CustomerID uint `gorm:"column:customer_id"`
AgingMax int `gorm:"column:aging_max"`
AgingRataRata float64 `gorm:"column:aging_rata_rata"`
}
type BalanceMonitoringGrandTotalsRow struct {
SaldoAwalLifetime float64 `gorm:"column:saldo_awal_lifetime"`
SalesBeforeStart float64 `gorm:"column:sales_before_start"`
PaymentBeforeStart float64 `gorm:"column:payment_before_start"`
AyamQty float64 `gorm:"column:ayam_qty"`
AyamKg float64 `gorm:"column:ayam_kg"`
AyamNominal float64 `gorm:"column:ayam_nominal"`
TelurQty float64 `gorm:"column:telur_qty"`
TelurKg float64 `gorm:"column:telur_kg"`
TelurNominal float64 `gorm:"column:telur_nominal"`
TradingQty float64 `gorm:"column:trading_qty"`
TradingKg float64 `gorm:"column:trading_kg"`
TradingNominal float64 `gorm:"column:trading_nominal"`
PaymentInPeriod float64 `gorm:"column:payment_in_period"`
AgingMax int `gorm:"column:aging_max"`
AgingRataRata float64 `gorm:"column:aging_rata_rata"`
}
type BalanceMonitoringRepository interface {
GetCustomerIDsForBalanceMonitoring(ctx context.Context, offset, limit int, filters *validation.BalanceMonitoringQuery) ([]uint, int64, error)
GetAllFilteredCustomerIDs(ctx context.Context, filters *validation.BalanceMonitoringQuery) ([]uint, error)
GetSaldoAwalLifetime(ctx context.Context, customerIDs []uint) (map[uint]float64, error)
GetSalesTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error)
GetPaymentTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error)
GetSalesByCategoryInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringCategoryRow, error)
GetPaymentTotalsInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error)
GetAgingPerCustomer(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringAgingRow, error)
GetGrandTotals(ctx context.Context, filters *validation.BalanceMonitoringQuery) (BalanceMonitoringGrandTotalsRow, error)
}
type balanceMonitoringRepositoryImpl struct {
db *gorm.DB
}
func NewBalanceMonitoringRepository(db *gorm.DB) BalanceMonitoringRepository {
return &balanceMonitoringRepositoryImpl{db: db}
}
func resolveBalanceMonitoringDateColumn(filterBy string) string {
switch strings.ToLower(strings.TrimSpace(filterBy)) {
case "realized_at":
return "mdp.delivery_date"
case "sold_at", "":
return "m.so_date"
default:
return "m.so_date"
}
}
func resolveBalanceMonitoringDateRange(filters *validation.BalanceMonitoringQuery) (time.Time, time.Time, error) {
var startDate time.Time
var endDate time.Time
var err error
if strings.TrimSpace(filters.StartDate) != "" {
startDate, err = utils.ParseDateString(filters.StartDate)
if err != nil {
return time.Time{}, time.Time{}, err
}
} else {
startDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
}
if strings.TrimSpace(filters.EndDate) != "" {
endDate, err = utils.ParseDateString(filters.EndDate)
if err != nil {
return time.Time{}, time.Time{}, err
}
} else {
endDate = time.Now()
}
return startDate, endDate, nil
}
func resolveBalanceMonitoringSortClause(filters *validation.BalanceMonitoringQuery) string {
direction := "ASC"
if strings.EqualFold(strings.TrimSpace(filters.SortOrder), "desc") {
direction = "DESC"
}
switch strings.ToLower(strings.TrimSpace(filters.SortBy)) {
case "customer":
return "customers.name " + direction
default:
return "customers.name ASC"
}
}
func (r *balanceMonitoringRepositoryImpl) baseCustomerQuery(ctx context.Context, filters *validation.BalanceMonitoringQuery) *gorm.DB {
db := r.db.WithContext(ctx).
Model(&entity.Customer{}).
Where("customers.deleted_at IS NULL")
if len(filters.CustomerIDs) > 0 {
db = db.Where("customers.id IN ?", filters.CustomerIDs)
}
if len(filters.SalesIDs) > 0 {
db = db.Where("EXISTS (SELECT 1 FROM marketings m WHERE m.customer_id = customers.id AND m.deleted_at IS NULL AND m.sales_person_id IN ?)", filters.SalesIDs)
}
if filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil {
scopeSub := r.db.WithContext(ctx).
Table("marketings m").
Select("1").
Joins("JOIN marketing_products mp ON mp.marketing_id = m.id").
Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id").
Joins("JOIN product_warehouses pw ON pw.id = mdp.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("m.customer_id = customers.id").
Where("m.deleted_at IS NULL").
Where("mdp.delivery_date IS NOT NULL")
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
scopeSub = scopeSub.Where("w.area_id IN ?", filters.AllowedAreaIDs)
}
}
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
scopeSub = scopeSub.Where("w.location_id IN ?", filters.AllowedLocationIDs)
}
}
db = db.Where("EXISTS (?)", scopeSub)
}
return db
}
func (r *balanceMonitoringRepositoryImpl) GetCustomerIDsForBalanceMonitoring(ctx context.Context, offset, limit int, filters *validation.BalanceMonitoringQuery) ([]uint, int64, error) {
var total int64
if err := r.baseCustomerQuery(ctx, filters).Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []uint{}, 0, nil
}
if offset < 0 {
offset = 0
}
var customerIDs []uint
err := r.baseCustomerQuery(ctx, filters).
Order(resolveBalanceMonitoringSortClause(filters)).
Limit(limit).
Offset(offset).
Pluck("customers.id", &customerIDs).
Error
if err != nil {
return nil, 0, err
}
return customerIDs, total, nil
}
func (r *balanceMonitoringRepositoryImpl) GetAllFilteredCustomerIDs(ctx context.Context, filters *validation.BalanceMonitoringQuery) ([]uint, error) {
var customerIDs []uint
if err := r.baseCustomerQuery(ctx, filters).Pluck("customers.id", &customerIDs).Error; err != nil {
return nil, err
}
return customerIDs, nil
}
func (r *balanceMonitoringRepositoryImpl) GetSaldoAwalLifetime(ctx context.Context, customerIDs []uint) (map[uint]float64, error) {
if len(customerIDs) == 0 {
return map[uint]float64{}, nil
}
type row struct {
CustomerID uint `gorm:"column:customer_id"`
Total float64 `gorm:"column:total"`
}
rows := make([]row, 0)
err := r.db.WithContext(ctx).
Model(&entity.Payment{}).
Select("party_id AS customer_id, COALESCE(SUM(nominal), 0) AS total").
Where("party_type = ?", string(utils.PaymentPartyCustomer)).
Where("transaction_type = ?", string(utils.TransactionTypeSaldoAwal)).
Where("party_id IN ?", customerIDs).
Group("party_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
result := make(map[uint]float64, len(rows))
for _, r := range rows {
result[r.CustomerID] = r.Total
}
return result, nil
}
func (r *balanceMonitoringRepositoryImpl) GetSalesTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) {
if len(customerIDs) == 0 {
return map[uint]float64{}, nil
}
startDate, _, err := resolveBalanceMonitoringDateRange(filters)
if err != nil {
return map[uint]float64{}, nil
}
dateColumn := resolveBalanceMonitoringDateColumn(filters.FilterBy)
type row struct {
CustomerID uint `gorm:"column:customer_id"`
Total float64 `gorm:"column:total"`
}
rows := make([]row, 0)
db := r.db.WithContext(ctx).
Table("marketing_delivery_products mdp").
Select("m.customer_id AS customer_id, COALESCE(SUM(mdp.total_price), 0) AS total").
Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("INNER JOIN marketings m ON m.id = mp.marketing_id").
Where("m.customer_id IN ?", customerIDs).
Where("m.deleted_at IS NULL").
Where("mdp.delivery_date IS NOT NULL").
Where(fmt.Sprintf("DATE(%s) < ?", dateColumn), startDate)
if len(filters.SalesIDs) > 0 {
db = db.Where("m.sales_person_id IN ?", filters.SalesIDs)
}
if err := db.Group("m.customer_id").Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]float64, len(rows))
for _, rr := range rows {
result[rr.CustomerID] = rr.Total
}
return result, nil
}
func (r *balanceMonitoringRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) {
if len(customerIDs) == 0 {
return map[uint]float64{}, nil
}
startDate, _, err := resolveBalanceMonitoringDateRange(filters)
if err != nil {
return map[uint]float64{}, nil
}
type row struct {
CustomerID uint `gorm:"column:customer_id"`
Total float64 `gorm:"column:total"`
}
rows := make([]row, 0)
err = r.db.WithContext(ctx).
Model(&entity.Payment{}).
Select("party_id AS customer_id, COALESCE(SUM(nominal), 0) AS total").
Where("party_type = ?", string(utils.PaymentPartyCustomer)).
Where("transaction_type = ?", string(utils.TransactionTypePenjualan)).
Where("direction = ?", "IN").
Where("party_id IN ?", customerIDs).
Where("DATE(payment_date) < ?", startDate).
Group("party_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
result := make(map[uint]float64, len(rows))
for _, rr := range rows {
result[rr.CustomerID] = rr.Total
}
return result, nil
}
func (r *balanceMonitoringRepositoryImpl) GetSalesByCategoryInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringCategoryRow, error) {
if len(customerIDs) == 0 {
return map[uint]BalanceMonitoringCategoryRow{}, nil
}
startDate, endDate, err := resolveBalanceMonitoringDateRange(filters)
if err != nil {
return map[uint]BalanceMonitoringCategoryRow{}, nil
}
dateColumn := resolveBalanceMonitoringDateColumn(filters.FilterBy)
rows := make([]BalanceMonitoringCategoryRow, 0)
db := r.db.WithContext(ctx).
Table("marketing_delivery_products mdp").
Select(`m.customer_id AS customer_id,
COALESCE(SUM(CASE WHEN m.marketing_type IN ('AYAM','AYAM_PULLET') THEN mdp.usage_qty ELSE 0 END), 0) AS ayam_qty,
COALESCE(SUM(CASE WHEN m.marketing_type IN ('AYAM','AYAM_PULLET') THEN mdp.total_weight ELSE 0 END), 0) AS ayam_kg,
COALESCE(SUM(CASE WHEN m.marketing_type IN ('AYAM','AYAM_PULLET') THEN mdp.total_price ELSE 0 END), 0) AS ayam_nominal,
COALESCE(SUM(CASE WHEN m.marketing_type = 'TELUR' THEN mdp.usage_qty ELSE 0 END), 0) AS telur_qty,
COALESCE(SUM(CASE WHEN m.marketing_type = 'TELUR' THEN mdp.total_weight ELSE 0 END), 0) AS telur_kg,
COALESCE(SUM(CASE WHEN m.marketing_type = 'TELUR' THEN mdp.total_price ELSE 0 END), 0) AS telur_nominal,
COALESCE(SUM(CASE WHEN m.marketing_type = 'TRADING' THEN mdp.usage_qty ELSE 0 END), 0) AS trading_qty,
COALESCE(SUM(CASE WHEN m.marketing_type = 'TRADING' THEN mdp.total_weight ELSE 0 END), 0) AS trading_kg,
COALESCE(SUM(CASE WHEN m.marketing_type = 'TRADING' THEN mdp.total_price ELSE 0 END), 0) AS trading_nominal`).
Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("INNER JOIN marketings m ON m.id = mp.marketing_id").
Where("m.customer_id IN ?", customerIDs).
Where("m.deleted_at IS NULL").
Where("mdp.delivery_date IS NOT NULL").
Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), startDate).
Where(fmt.Sprintf("DATE(%s) <= ?", dateColumn), endDate)
if len(filters.SalesIDs) > 0 {
db = db.Where("m.sales_person_id IN ?", filters.SalesIDs)
}
if err := db.Group("m.customer_id").Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]BalanceMonitoringCategoryRow, len(rows))
for _, rr := range rows {
result[rr.CustomerID] = rr
}
return result, nil
}
func (r *balanceMonitoringRepositoryImpl) GetPaymentTotalsInPeriod(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]float64, error) {
if len(customerIDs) == 0 {
return map[uint]float64{}, nil
}
startDate, endDate, err := resolveBalanceMonitoringDateRange(filters)
if err != nil {
return map[uint]float64{}, nil
}
type row struct {
CustomerID uint `gorm:"column:customer_id"`
Total float64 `gorm:"column:total"`
}
rows := make([]row, 0)
err = r.db.WithContext(ctx).
Model(&entity.Payment{}).
Select("party_id AS customer_id, COALESCE(SUM(nominal), 0) AS total").
Where("party_type = ?", string(utils.PaymentPartyCustomer)).
Where("transaction_type = ?", string(utils.TransactionTypePenjualan)).
Where("direction = ?", "IN").
Where("party_id IN ?", customerIDs).
Where("DATE(payment_date) >= ?", startDate).
Where("DATE(payment_date) <= ?", endDate).
Group("party_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
result := make(map[uint]float64, len(rows))
for _, rr := range rows {
result[rr.CustomerID] = rr.Total
}
return result, nil
}
func (r *balanceMonitoringRepositoryImpl) GetAgingPerCustomer(ctx context.Context, customerIDs []uint, filters *validation.BalanceMonitoringQuery) (map[uint]BalanceMonitoringAgingRow, error) {
if len(customerIDs) == 0 {
return map[uint]BalanceMonitoringAgingRow{}, nil
}
startDate, endDate, err := resolveBalanceMonitoringDateRange(filters)
if err != nil {
return map[uint]BalanceMonitoringAgingRow{}, nil
}
dateColumn := resolveBalanceMonitoringDateColumn(filters.FilterBy)
rows := make([]BalanceMonitoringAgingRow, 0)
db := r.db.WithContext(ctx).
Table("marketing_delivery_products mdp").
Select(`m.customer_id AS customer_id,
COALESCE(MAX(GREATEST(CURRENT_DATE - DATE(mdp.delivery_date), 0)), 0) AS aging_max,
COALESCE(
SUM(mdp.total_price * GREATEST(CURRENT_DATE - DATE(mdp.delivery_date), 0))::numeric
/ NULLIF(SUM(mdp.total_price), 0),
0
)::numeric(15,2) AS aging_rata_rata`).
Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("INNER JOIN marketings m ON m.id = mp.marketing_id").
Where("m.customer_id IN ?", customerIDs).
Where("m.deleted_at IS NULL").
Where("mdp.delivery_date IS NOT NULL").
Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), startDate).
Where(fmt.Sprintf("DATE(%s) <= ?", dateColumn), endDate)
if len(filters.SalesIDs) > 0 {
db = db.Where("m.sales_person_id IN ?", filters.SalesIDs)
}
if err := db.Group("m.customer_id").Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]BalanceMonitoringAgingRow, len(rows))
for _, rr := range rows {
result[rr.CustomerID] = rr
}
return result, nil
}
func (r *balanceMonitoringRepositoryImpl) GetGrandTotals(ctx context.Context, filters *validation.BalanceMonitoringQuery) (BalanceMonitoringGrandTotalsRow, error) {
customerIDs, err := r.GetAllFilteredCustomerIDs(ctx, filters)
if err != nil {
return BalanceMonitoringGrandTotalsRow{}, err
}
if len(customerIDs) == 0 {
return BalanceMonitoringGrandTotalsRow{}, nil
}
saldoAwalLifetimeMap, err := r.GetSaldoAwalLifetime(ctx, customerIDs)
if err != nil {
return BalanceMonitoringGrandTotalsRow{}, err
}
salesBeforeMap, err := r.GetSalesTotalsBeforeDate(ctx, customerIDs, filters)
if err != nil {
return BalanceMonitoringGrandTotalsRow{}, err
}
paymentBeforeMap, err := r.GetPaymentTotalsBeforeDate(ctx, customerIDs, filters)
if err != nil {
return BalanceMonitoringGrandTotalsRow{}, err
}
categoryMap, err := r.GetSalesByCategoryInPeriod(ctx, customerIDs, filters)
if err != nil {
return BalanceMonitoringGrandTotalsRow{}, err
}
paymentInPeriodMap, err := r.GetPaymentTotalsInPeriod(ctx, customerIDs, filters)
if err != nil {
return BalanceMonitoringGrandTotalsRow{}, err
}
agingMap, err := r.GetAgingPerCustomer(ctx, customerIDs, filters)
if err != nil {
return BalanceMonitoringGrandTotalsRow{}, err
}
totals := BalanceMonitoringGrandTotalsRow{}
for _, total := range saldoAwalLifetimeMap {
totals.SaldoAwalLifetime += total
}
for _, total := range salesBeforeMap {
totals.SalesBeforeStart += total
}
for _, total := range paymentBeforeMap {
totals.PaymentBeforeStart += total
}
for _, cat := range categoryMap {
totals.AyamQty += cat.AyamQty
totals.AyamKg += cat.AyamKg
totals.AyamNominal += cat.AyamNominal
totals.TelurQty += cat.TelurQty
totals.TelurKg += cat.TelurKg
totals.TelurNominal += cat.TelurNominal
totals.TradingQty += cat.TradingQty
totals.TradingKg += cat.TradingKg
totals.TradingNominal += cat.TradingNominal
}
for _, total := range paymentInPeriodMap {
totals.PaymentInPeriod += total
}
for _, aging := range agingMap {
totals.AgingMax += aging.AgingMax
}
weightedSum := 0.0
weightTotal := 0.0
for cid, cat := range categoryMap {
nominal := cat.AyamNominal + cat.TelurNominal + cat.TradingNominal
if aging, ok := agingMap[cid]; ok && nominal > 0 {
weightedSum += nominal * aging.AgingRataRata
weightTotal += nominal
}
}
if weightTotal > 0 {
totals.AgingRataRata = weightedSum / weightTotal
}
return totals, nil
}
@@ -2,7 +2,7 @@ package repositories
import (
"context"
"strings"
"time"
"gorm.io/gorm"
@@ -30,7 +30,7 @@ type CustomerPaymentTransaction struct {
type CustomerPaymentRepository interface {
GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error)
GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error)
GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint, sortBy, sortOrder string) ([]uint, int64, error)
GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint) ([]uint, int64, error)
}
type customerPaymentRepositoryImpl struct {
@@ -146,34 +146,21 @@ func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context.
return result.Nominal, nil
}
func resolveCustomerPaymentSortClause(sortBy, sortOrder string) string {
direction := "ASC"
if strings.EqualFold(strings.TrimSpace(sortOrder), "desc") {
direction = "DESC"
}
switch strings.ToLower(strings.TrimSpace(sortBy)) {
case "customer":
return "customer_name " + direction
default:
return "customer_name ASC"
}
}
func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint) ([]uint, int64, error) {
subQuery := r.db.WithContext(ctx).
Table("(" +
"SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " +
"INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " +
"INNER JOIN marketings m ON m.id = mp.marketing_id " +
"INNER JOIN customers c ON c.id = m.customer_id " +
"WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " +
"UNION " +
"SELECT DISTINCT c.id as customer_id FROM payments p " +
"INNER JOIN customers c ON c.id = p.party_id " +
"WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " +
"AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" +
") as customer_ids")
func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint, sortBy, sortOrder string) ([]uint, int64, error) {
unionSQL := "(" +
"SELECT DISTINCT c.id as customer_id, c.name as customer_name FROM marketing_delivery_products mdp " +
"INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " +
"INNER JOIN marketings m ON m.id = mp.marketing_id " +
"INNER JOIN customers c ON c.id = m.customer_id " +
"WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " +
"UNION " +
"SELECT DISTINCT c.id as customer_id, c.name as customer_name FROM payments p " +
"INNER JOIN customers c ON c.id = p.party_id " +
"WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " +
"AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" +
") as customer_ids"
subQuery := r.db.WithContext(ctx).Table(unionSQL)
if len(allowedCustomerIDs) > 0 {
subQuery = subQuery.Where("customer_id IN ?", allowedCustomerIDs)
}
@@ -183,14 +170,28 @@ func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx conte
return nil, 0, err
}
query := r.db.WithContext(ctx).Table(unionSQL).Select("customer_id")
var customerIDs []uint
query := r.db.WithContext(ctx).
Table("(" +
"SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " +
"INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " +
"INNER JOIN marketings m ON m.id = mp.marketing_id " +
"INNER JOIN customers c ON c.id = m.customer_id " +
"WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " +
"UNION " +
"SELECT DISTINCT c.id as customer_id FROM payments p " +
"INNER JOIN customers c ON c.id = p.party_id " +
"WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " +
"AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" +
") as customer_ids").
Select("customer_id")
if len(allowedCustomerIDs) > 0 {
query = query.Where("customer_id IN ?", allowedCustomerIDs)
}
var customerIDs []uint
err := query.
Order(resolveCustomerPaymentSortClause(sortBy, sortOrder)).
Order("customer_id ASC").
Limit(limit).
Offset(offset).
Pluck("customer_id", &customerIDs).
@@ -15,16 +15,13 @@ import (
type DebtSupplierRepository interface {
GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error)
GetSuppliersWithDebts(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error)
GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error)
GetExpensesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Expense, error)
GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error)
GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error)
GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error)
GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error)
GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
GetExpenseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
}
type debtSupplierRepositoryImpl struct {
@@ -55,19 +52,6 @@ func (r *debtSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context)
)
}
func resolveDebtSupplierSortClause(filters *validation.DebtSupplierQuery) string {
direction := "ASC"
if strings.EqualFold(strings.TrimSpace(filters.SortOrder), "desc") {
direction = "DESC"
}
switch strings.ToLower(strings.TrimSpace(filters.SortBy)) {
case "supplier":
return "suppliers.name " + direction
default:
return "suppliers.name ASC"
}
}
func resolveDebtSupplierDateColumn(filterBy string) string {
switch strings.ToLower(strings.TrimSpace(filterBy)) {
case "po_date":
@@ -145,24 +129,15 @@ func (r *debtSupplierRepositoryImpl) GetSuppliersWithPurchases(ctx context.Conte
offset = 0
}
type supplierIDResult struct {
ID uint `gorm:"column:id"`
Name string `gorm:"column:name"`
}
var idResults []supplierIDResult
if err := r.baseSupplierQuery(ctx, filters).
Select("suppliers.id, suppliers.name").
Group("suppliers.id, suppliers.name").
Order(resolveDebtSupplierSortClause(filters)).
var supplierIDs []uint
if err := query.
Select("suppliers.id").
Order("suppliers.id ASC").
Offset(offset).
Limit(limit).
Scan(&idResults).Error; err != nil {
Pluck("suppliers.id", &supplierIDs).Error; err != nil {
return nil, 0, err
}
supplierIDs := make([]uint, 0, len(idResults))
for _, r := range idResults {
supplierIDs = append(supplierIDs, r.ID)
}
if len(supplierIDs) == 0 {
return []entity.Supplier{}, totalSuppliers, nil
@@ -171,7 +146,6 @@ func (r *debtSupplierRepositoryImpl) GetSuppliersWithPurchases(ctx context.Conte
var suppliers []entity.Supplier
if err := r.db.WithContext(ctx).
Where("id IN ?", supplierIDs).
Order(resolveDebtSupplierSortClause(filters)).
Find(&suppliers).Error; err != nil {
return nil, 0, err
}
@@ -493,218 +467,3 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Cont
return result, nil
}
func (r *debtSupplierRepositoryImpl) latestExpenseApproval(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).
Table("approvals AS a").
Select("a.approvable_id, a.step_number, a.action").
Joins(`
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = ?
GROUP BY approvable_id
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
string(utils.ApprovalWorkflowExpense),
)
}
func (r *debtSupplierRepositoryImpl) baseExpenseSupplierIDs(ctx context.Context, filters *validation.DebtSupplierQuery) *gorm.DB {
db := r.db.WithContext(ctx).
Table("expenses").
Select("DISTINCT expenses.supplier_id").
Joins("JOIN (?) AS la ON la.approvable_id = expenses.id", r.latestExpenseApproval(ctx)).
Where("la.step_number >= ?", uint16(utils.ExpenseStepFinance)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("expenses.deleted_at IS NULL")
if len(filters.SupplierIDs) > 0 {
db = db.Where("expenses.supplier_id IN ?", filters.SupplierIDs)
}
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("expenses.location_id IN ?", filters.AllowedLocationIDs)
}
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Joins("JOIN locations exp_loc ON exp_loc.id = expenses.location_id").
Where("exp_loc.area_id IN ?", filters.AllowedAreaIDs)
}
}
if filters.StartDate != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where("DATE(expenses.transaction_date) >= ?", dateFrom)
}
}
if filters.EndDate != "" {
if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil {
db = db.Where("DATE(expenses.transaction_date) <= ?", dateTo)
}
}
return db
}
func (r *debtSupplierRepositoryImpl) GetSuppliersWithDebts(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) {
purchaseSubquery := r.baseSupplierQuery(ctx, filters).
Select("suppliers.id")
expenseSubquery := r.baseExpenseSupplierIDs(ctx, filters)
db := r.db.WithContext(ctx).
Model(&entity.Supplier{}).
Where("suppliers.id IN (? UNION ?) AND suppliers.deleted_at IS NULL",
purchaseSubquery, expenseSubquery)
var totalSuppliers int64
if err := db.Distinct("suppliers.id").Count(&totalSuppliers).Error; err != nil {
return nil, 0, err
}
if totalSuppliers == 0 {
return []entity.Supplier{}, 0, nil
}
if offset < 0 {
offset = 0
}
type supplierIDResult struct {
ID uint `gorm:"column:id"`
Name string `gorm:"column:name"`
}
var idResults []supplierIDResult
if err := r.db.WithContext(ctx).
Model(&entity.Supplier{}).
Where("suppliers.id IN (? UNION ?) AND suppliers.deleted_at IS NULL",
purchaseSubquery, expenseSubquery).
Select("suppliers.id, suppliers.name").
Group("suppliers.id, suppliers.name").
Order(resolveDebtSupplierSortClause(filters)).
Offset(offset).
Limit(limit).
Scan(&idResults).Error; err != nil {
return nil, 0, err
}
supplierIDs := make([]uint, 0, len(idResults))
for _, r := range idResults {
supplierIDs = append(supplierIDs, r.ID)
}
if len(supplierIDs) == 0 {
return []entity.Supplier{}, totalSuppliers, nil
}
var suppliers []entity.Supplier
if err := r.db.WithContext(ctx).
Where("id IN ?", supplierIDs).
Order(resolveDebtSupplierSortClause(filters)).
Find(&suppliers).Error; err != nil {
return nil, 0, err
}
return suppliers, totalSuppliers, nil
}
func (r *debtSupplierRepositoryImpl) GetExpensesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Expense, error) {
if len(supplierIDs) == 0 {
return []entity.Expense{}, nil
}
db := r.db.WithContext(ctx).
Model(&entity.Expense{}).
Joins("JOIN (?) AS la ON la.approvable_id = expenses.id", r.latestExpenseApproval(ctx)).
Where("expenses.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.ExpenseStepFinance)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("expenses.deleted_at IS NULL")
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("expenses.location_id IN ?", filters.AllowedLocationIDs)
}
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Joins("JOIN locations exp_loc ON exp_loc.id = expenses.location_id").
Where("exp_loc.area_id IN ?", filters.AllowedAreaIDs)
}
}
if filters.StartDate != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where("DATE(expenses.transaction_date) >= ?", dateFrom)
}
}
if filters.EndDate != "" {
if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil {
db = db.Where("DATE(expenses.transaction_date) <= ?", dateTo)
}
}
var expenses []entity.Expense
if err := db.
Preload("Supplier").
Preload("Nonstocks").
Preload("Location").
Preload("Location.Area").
Order("expenses.transaction_date ASC, expenses.id ASC").
Find(&expenses).Error; err != nil {
return nil, err
}
return expenses, nil
}
func (r *debtSupplierRepositoryImpl) GetExpenseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) {
if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" {
return map[uint]float64{}, nil
}
dateFrom, err := utils.ParseDateString(filters.StartDate)
if err != nil {
return map[uint]float64{}, nil
}
type expenseTotalRow struct {
SupplierID uint `gorm:"column:supplier_id"`
Total float64 `gorm:"column:total"`
}
rows := make([]expenseTotalRow, 0)
if err := r.db.WithContext(ctx).
Table("expenses").
Select("expenses.supplier_id AS supplier_id, SUM(en.qty * en.price) AS total").
Joins("JOIN expense_nonstocks en ON en.expense_id = expenses.id").
Joins("JOIN (?) AS la ON la.approvable_id = expenses.id", r.latestExpenseApproval(ctx)).
Where("expenses.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.ExpenseStepFinance)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("expenses.deleted_at IS NULL").
Where("DATE(expenses.transaction_date) < ?", dateFrom).
Group("expenses.supplier_id").
Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]float64, len(rows))
for _, row := range rows {
result[row.SupplierID] = row.Total
}
return result, nil
}
-1
View File
@@ -26,5 +26,4 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService
route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown)
route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult)
route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment)
route.Get("/balance-monitoring", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetBalanceMonitoring)
}
@@ -52,7 +52,6 @@ type RepportService interface {
GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error)
GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error)
GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error)
GetBalanceMonitoring(ctx *fiber.Ctx, params *validation.BalanceMonitoringQuery) ([]dto.BalanceMonitoringRowDTO, dto.BalanceMonitoringTotalsDTO, int64, error)
DB() *gorm.DB
}
@@ -75,7 +74,6 @@ type repportService struct {
HppPerKandangRepo repportRepo.HppPerKandangRepository
ProductionResultRepo repportRepo.ProductionResultRepository
CustomerPaymentRepo repportRepo.CustomerPaymentRepository
BalanceMonitoringRepo repportRepo.BalanceMonitoringRepository
CustomerRepo customerRepo.CustomerRepository
StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository
ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository
@@ -108,7 +106,6 @@ func NewRepportService(
hppPerKandangRepo repportRepo.HppPerKandangRepository,
productionResultRepo repportRepo.ProductionResultRepository,
customerPaymentRepo repportRepo.CustomerPaymentRepository,
balanceMonitoringRepo repportRepo.BalanceMonitoringRepository,
customerRepo customerRepo.CustomerRepository,
standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository,
productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository,
@@ -132,7 +129,6 @@ func NewRepportService(
HppPerKandangRepo: hppPerKandangRepo,
ProductionResultRepo: productionResultRepo,
CustomerPaymentRepo: customerPaymentRepo,
BalanceMonitoringRepo: balanceMonitoringRepo,
CustomerRepo: customerRepo,
StandardGrowthDetailRepo: standardGrowthDetailRepo,
ProductionStandardDetailRepo: productionStandardDetailRepo,
@@ -1033,13 +1029,6 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation.
}
func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) {
if params.SortBy == "" {
params.SortBy = "customer"
}
if params.SortOrder == "" {
params.SortOrder = "asc"
}
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
@@ -1094,7 +1083,7 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C
offset := (page - 1) * limit
var err error
customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset, allowedCustomerIDs, params.SortBy, params.SortOrder)
customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset, allowedCustomerIDs)
if err != nil {
return nil, 0, err
}
@@ -1766,12 +1755,6 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
if params.FilterBy == "" {
params.FilterBy = "received_date"
}
if params.SortBy == "" {
params.SortBy = "supplier"
}
if params.SortOrder == "" {
params.SortOrder = "asc"
}
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -1782,7 +1765,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
offset = 0
}
suppliers, totalSuppliers, err := s.DebtSupplierRepo.GetSuppliersWithDebts(c.Context(), offset, params.Limit, params)
suppliers, totalSuppliers, err := s.DebtSupplierRepo.GetSuppliersWithPurchases(c.Context(), offset, params.Limit, params)
if err != nil {
return nil, 0, err
}
@@ -1807,21 +1790,11 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
return nil, 0, err
}
expenses, err := s.DebtSupplierRepo.GetExpensesBySuppliers(c.Context(), supplierIDs, params)
if err != nil {
return nil, 0, err
}
purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs))
for _, purchase := range purchases {
purchasesBySupplier[purchase.SupplierId] = append(purchasesBySupplier[purchase.SupplierId], purchase)
}
expensesBySupplier := make(map[uint][]entity.Expense, len(supplierIDs))
for _, exp := range expenses {
expensesBySupplier[uint(exp.SupplierId)] = append(expensesBySupplier[uint(exp.SupplierId)], exp)
}
paymentsBySupplier := make(map[uint][]entity.Payment, len(supplierIDs))
for _, payment := range payments {
paymentsBySupplier[payment.PartyId] = append(paymentsBySupplier[payment.PartyId], payment)
@@ -1837,11 +1810,6 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
return nil, 0, err
}
initialExpenseTotals, err := s.DebtSupplierRepo.GetExpenseTotalsBeforeDate(c.Context(), supplierIDs, params)
if err != nil {
return nil, 0, err
}
initialBalanceTotals, err := s.DebtSupplierRepo.GetInitialBalanceTotals(c.Context(), supplierIDs)
if err != nil {
return nil, 0, err
@@ -1862,10 +1830,10 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
CountTotals bool
}
type debtSupplierAllocation struct {
RowIndex int
SortTime time.Time
Amount float64
CalcAging func(endDate time.Time) int
RowIndex int
SortTime time.Time
Amount float64
Purchase entity.Purchase
}
type paymentAllocation struct {
Date time.Time
@@ -1878,7 +1846,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
continue
}
initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] - initialExpenseTotals[supplierID])
initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID])
items := purchasesBySupplier[supplierID]
paymentItems := paymentsBySupplier[supplierID]
total := dto.DebtSupplierTotalDTO{}
@@ -1896,32 +1864,11 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
DeltaBalance: -row.TotalPrice,
CountTotals: true,
})
capturedPurchase := purchase
purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{
RowIndex: rowIndex,
SortTime: sortTime,
Amount: row.TotalPrice,
CalcAging: func(endDate time.Time) int { return calculateDebtSupplierAging(capturedPurchase, endDate, location) },
})
}
for _, exp := range expensesBySupplier[supplierID] {
row := buildDebtSupplierExpenseRow(exp, now, location)
sortTime := exp.TransactionDate.In(location)
rowIndex := len(combinedRows)
combinedRows = append(combinedRows, debtSupplierRowItem{
Row: row,
SortTime: sortTime,
Order: 0,
DeltaBalance: -row.TotalPrice,
CountTotals: true,
})
capturedExp := exp
purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{
RowIndex: rowIndex,
SortTime: sortTime,
Amount: row.TotalPrice,
CalcAging: func(endDate time.Time) int { return calculateExpenseAging(capturedExp, endDate, location) },
RowIndex: rowIndex,
SortTime: sortTime,
Amount: row.TotalPrice,
Purchase: purchase,
})
}
@@ -1986,7 +1933,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
if remaining[purchaseIndex] <= 0.000001 {
allocation := purchaseAllocations[purchaseIndex]
combinedRows[allocation.RowIndex].Row.Status = "Lunas"
combinedRows[allocation.RowIndex].Row.Aging = allocation.CalcAging(pay.Date)
combinedRows[allocation.RowIndex].Row.Aging = calculateDebtSupplierAging(allocation.Purchase, pay.Date, location)
purchaseIndex++
}
}
@@ -2260,62 +2207,6 @@ func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Locatio
return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc)
}
func buildDebtSupplierExpenseRow(exp entity.Expense, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO {
txDate := exp.TransactionDate.In(loc)
dateStr := txDate.Format("2006-01-02")
startDay := time.Date(txDate.Year(), txDate.Month(), txDate.Day(), 0, 0, 0, 0, loc)
endDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
aging := 0
if !startDay.IsZero() && !endDay.Before(startDay) {
aging = int(endDay.Sub(startDay).Hours() / 24)
}
totalPrice := 0.0
for _, ns := range exp.Nonstocks {
totalPrice += ns.Qty * ns.Price
}
var area *areaDTO.AreaRelationDTO
if exp.Location != nil && exp.Location.Area.Id != 0 {
mapped := areaDTO.ToAreaRelationDTO(exp.Location.Area)
area = &mapped
}
poNumber := ""
if strings.TrimSpace(exp.PoNumber) != "" {
poNumber = exp.PoNumber
}
return dto.DebtSupplierRowDTO{
PrNumber: exp.ReferenceNumber,
PoNumber: poNumber,
PoDate: dateStr,
ReceivedDate: dateStr,
Aging: aging,
Area: area,
Warehouse: nil,
DueDate: "-",
DueStatus: "-",
TotalPrice: totalPrice,
PaymentPrice: 0,
DebtPrice: 0,
Status: "Belum Lunas",
TravelNumber: "-",
Balance: 0,
}
}
func calculateExpenseAging(exp entity.Expense, endDate time.Time, loc *time.Location) int {
start := exp.TransactionDate.In(loc)
startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc)
stopDay := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc)
if stopDay.Before(startDay) {
return 0
}
return int(stopDay.Sub(startDay).Hours() / 24)
}
func (s *repportService) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
@@ -2989,163 +2880,3 @@ func parseOptionalFloat64(raw string) (*float64, error) {
return &value, nil
}
func (s *repportService) GetBalanceMonitoring(ctx *fiber.Ctx, params *validation.BalanceMonitoringQuery) ([]dto.BalanceMonitoringRowDTO, dto.BalanceMonitoringTotalsDTO, int64, error) {
if params.SortBy == "" {
params.SortBy = "customer"
}
if params.SortOrder == "" {
params.SortOrder = "asc"
}
if params.FilterBy == "" {
params.FilterBy = "sold_at"
}
if params.Page < 1 {
params.Page = 1
}
if params.Limit < 1 {
params.Limit = 10
}
if err := s.Validate.Struct(params); err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
locationScope, err := m.ResolveLocationScope(ctx, s.DB())
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
areaScope, err := m.ResolveAreaScope(ctx, s.DB())
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
if locationScope.Restrict {
params.AllowedLocationIDs = toInt64Slice(locationScope.IDs)
}
if areaScope.Restrict {
params.AllowedAreaIDs = toInt64Slice(areaScope.IDs)
}
offset := (params.Page - 1) * params.Limit
customerIDs, total, err := s.BalanceMonitoringRepo.GetCustomerIDsForBalanceMonitoring(ctx.Context(), offset, params.Limit, params)
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
if len(customerIDs) == 0 {
emptyTotals, gtErr := s.computeBalanceMonitoringTotals(ctx.Context(), params)
if gtErr != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, gtErr
}
return []dto.BalanceMonitoringRowDTO{}, emptyTotals, total, nil
}
saldoAwalLifetimeMap, err := s.BalanceMonitoringRepo.GetSaldoAwalLifetime(ctx.Context(), customerIDs)
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
salesBeforeMap, err := s.BalanceMonitoringRepo.GetSalesTotalsBeforeDate(ctx.Context(), customerIDs, params)
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
paymentBeforeMap, err := s.BalanceMonitoringRepo.GetPaymentTotalsBeforeDate(ctx.Context(), customerIDs, params)
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
categoryMap, err := s.BalanceMonitoringRepo.GetSalesByCategoryInPeriod(ctx.Context(), customerIDs, params)
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
paymentInPeriodMap, err := s.BalanceMonitoringRepo.GetPaymentTotalsInPeriod(ctx.Context(), customerIDs, params)
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
agingMap, err := s.BalanceMonitoringRepo.GetAgingPerCustomer(ctx.Context(), customerIDs, params)
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
customers, err := s.CustomerRepo.GetByIDs(ctx.Context(), customerIDs, func(db *gorm.DB) *gorm.DB {
return db.Preload("Pic")
})
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
customerMap := make(map[uint]entity.Customer, len(customers))
for _, c := range customers {
customerMap[c.Id] = c
}
result := make([]dto.BalanceMonitoringRowDTO, 0, len(customerIDs))
for _, customerID := range customerIDs {
customer, ok := customerMap[customerID]
if !ok {
continue
}
saldoAwal := saldoAwalLifetimeMap[customerID] + paymentBeforeMap[customerID] - salesBeforeMap[customerID]
category := categoryMap[customerID]
ayam := dto.BalanceMonitoringAyamDTO{
Ekor: category.AyamQty,
Kg: category.AyamKg,
Nominal: category.AyamNominal,
}
telur := dto.BalanceMonitoringTelurDTO{
Butir: category.TelurQty,
Kg: category.TelurKg,
Nominal: category.TelurNominal,
}
trading := dto.BalanceMonitoringTradingDTO{
Qty: category.TradingQty,
Kg: category.TradingKg,
Nominal: category.TradingNominal,
}
pembayaran := paymentInPeriodMap[customerID]
aging := agingMap[customerID]
row := dto.ToBalanceMonitoringRowDTO(customer, saldoAwal, ayam, telur, trading, pembayaran, aging.AgingMax, aging.AgingRataRata)
result = append(result, row)
}
totals, err := s.computeBalanceMonitoringTotals(ctx.Context(), params)
if err != nil {
return nil, dto.BalanceMonitoringTotalsDTO{}, 0, err
}
return result, totals, total, nil
}
func (s *repportService) computeBalanceMonitoringTotals(ctx context.Context, params *validation.BalanceMonitoringQuery) (dto.BalanceMonitoringTotalsDTO, error) {
grand, err := s.BalanceMonitoringRepo.GetGrandTotals(ctx, params)
if err != nil {
return dto.BalanceMonitoringTotalsDTO{}, err
}
saldoAwal := grand.SaldoAwalLifetime + grand.PaymentBeforeStart - grand.SalesBeforeStart
saldoAkhir := saldoAwal + grand.PaymentInPeriod - (grand.AyamNominal + grand.TelurNominal + grand.TradingNominal)
return dto.BalanceMonitoringTotalsDTO{
SaldoAwal: saldoAwal,
PenjualanAyam: dto.BalanceMonitoringAyamDTO{
Ekor: grand.AyamQty,
Kg: grand.AyamKg,
Nominal: grand.AyamNominal,
},
PenjualanTelur: dto.BalanceMonitoringTelurDTO{
Butir: grand.TelurQty,
Kg: grand.TelurKg,
Nominal: grand.TelurNominal,
},
PenjualanTrading: dto.BalanceMonitoringTradingDTO{
Qty: grand.TradingQty,
Kg: grand.TradingKg,
Nominal: grand.TradingNominal,
},
Pembayaran: grand.PaymentInPeriod,
Aging: grand.AgingMax,
AgingRataRata: grand.AgingRataRata,
SaldoAkhir: saldoAkhir,
}, nil
}
@@ -13,8 +13,6 @@ type ExpenseQuery struct {
AreaId int64 `query:"area_id" validate:"omitempty"`
LocationId int64 `query:"location_id" validate:"omitempty"`
RealizationDate string `query:"realization_date" validate:"omitempty"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=po_number reference_number realization_date transaction_date category product supplier location kandang qty_pengajuan price_pengajuan total_pengajuan qty_realisasi price_realisasi total_realisasi"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
AllowedAreaIDs []int64 `query:"-"`
AllowedLocationIDs []int64 `query:"-"`
}
@@ -60,7 +58,6 @@ type DebtSupplierQuery struct {
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=supplier"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
AllowedAreaIDs []int64 `query:"-"`
AllowedLocationIDs []int64 `query:"-"`
@@ -111,22 +108,6 @@ type CustomerPaymentQuery struct {
Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=TRANS_DATE REALIZATION_DATE"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=customer"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
}
type BalanceMonitoringQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
CustomerIDs []uint `query:"-" validate:"omitempty,dive,gt=0"`
SalesIDs []uint `query:"-" validate:"omitempty,dive,gt=0"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=sold_at realized_at"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=customer"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
AllowedAreaIDs []int64 `query:"-"`
AllowedLocationIDs []int64 `query:"-"`
}