Compare commits

..

28 Commits

Author SHA1 Message Date
giovanni 495f5f5cc1 adjust export format purchase and filter 2026-05-21 11:48:24 +07:00
Giovanni Gabriel Septriadi 71e80634b1 Merge branch 'feat/bop-finance' into 'development'
add vendor ekspedisi to laporan keuangan

See merge request mbugroup/lti-api!546
2026-05-21 01:42:48 +00:00
giovanni af2b3366ba add vendor ekspedisi to laporan keuanga 2026-05-20 23:10:08 +07:00
Giovanni Gabriel Septriadi e015e20b5c Merge branch 'fix/expenses' into 'development'
[FIX][BE]: adjust response get report expense

See merge request mbugroup/lti-api!545
2026-05-20 07:40:50 +00:00
giovanni d92d28c892 adjust response get report expense 2026-05-20 14:39:36 +07:00
Giovanni Gabriel Septriadi 60bdd4a31a Merge branch 'feat/monitoring-saldo' into 'development'
add api monitoring saldo customer

See merge request mbugroup/lti-api!544
2026-05-20 03:35:50 +00:00
Giovanni Gabriel Septriadi cce0d44f83 Merge branch 'feat/lap-keuangan' into 'development'
add export customer payment control

See merge request mbugroup/lti-api!543
2026-05-20 01:49:58 +00:00
giovanni c8623e2f7c add export customer payment control 2026-05-19 22:42:46 +07:00
giovanni 6fc4ad5773 add api monitoring saldo customer 2026-05-19 18:42:57 +07:00
Giovanni Gabriel Septriadi e61625d2f7 Merge branch 'feat/lap-keuangan' into 'development'
[FEAT][BE]: add export laporan keuangan hutang ke supplier

See merge request mbugroup/lti-api!542
2026-05-19 11:41:05 +00:00
giovanni 907b695526 add export laporang keuangan hutang ke supplier 2026-05-19 18:38:58 +07:00
Giovanni Gabriel Septriadi 32c34be2c6 Merge branch 'fix/30' into 'development'
[FIX][BE]: fix status marketing when edit sales order and edit marketing delivery

See merge request mbugroup/lti-api!540
2026-05-19 05:16:40 +00:00
giovanni d2aa3ebac7 fix status marketing when edit sales order and edit marketing delivery 2026-05-19 12:15:53 +07:00
Giovanni Gabriel Septriadi 02b86be4c5 Merge branch 'feat/edit-dc' into 'development'
[FIX][BE]: fix detail daily checklist empty kandang; add sorting to report biaya operasional

See merge request mbugroup/lti-api!539
2026-05-19 01:47:53 +00:00
giovanni 99e185a16a ad sorting to laporan biaya operasional 2026-05-19 00:15:08 +07:00
giovanni 995d585f54 fix get detail kandang kosong 2026-05-18 22:45:30 +07:00
Giovanni Gabriel Septriadi d05be1aef4 Merge branch 'feat/edit-dc' into 'development'
[FEAT][BE]: fix get search keuangan; add bank name to supplier and customer

See merge request mbugroup/lti-api!538
2026-05-17 13:50:15 +00:00
giovanni 872a71efda fix get search keuangan; add bank name to supplier and customer 2026-05-17 20:48:13 +07:00
Giovanni Gabriel Septriadi 6a2d6eec92 Merge branch 'feat/edit-dc' into 'development'
[FEAT][BE]: daily checklist can edit empty kandanG

See merge request mbugroup/lti-api!537
2026-05-17 12:12:27 +00:00
giovanni 18f9da1eaf daily checklist can edit empty kandang kosong 2026-05-17 19:11:13 +07:00
Giovanni Gabriel Septriadi 45bbe2ab1b Merge branch 'fix/sorting' into 'development'
[FIX][BE]: add sorting transaction, report keuangan

See merge request mbugroup/lti-api!536
2026-05-16 16:41:46 +00:00
giovanni 18bd8ad1d9 add sorting transaction, report keuangan 2026-05-16 23:40:52 +07:00
Giovanni Gabriel Septriadi a40adc22d2 Merge branch 'feat/sort-po-ex' into 'development'
[FIX][BE]: adjust sorting pembelian dan expenses

See merge request mbugroup/lti-api!535
2026-05-13 08:35:17 +00:00
giovanni 04626560eb adjust sorting pembelian dan expenses 2026-05-13 15:34:24 +07:00
Giovanni Gabriel Septriadi 945683bdf5 Merge branch 'feat/sort-po-ex' into 'development'
[FEAT][BE]: add sorting server side po and expense

See merge request mbugroup/lti-api!534
2026-05-13 06:31:27 +00:00
giovanni 490c7fc9fd add sorting server side po and expense 2026-05-13 13:28:37 +07:00
Giovanni Gabriel Septriadi 4f03b631ef Merge branch 'fix/pay' into 'development'
[FIX][BE]: adjust calculate total price  marketing ayam pullet

See merge request mbugroup/lti-api!533
2026-05-13 02:41:24 +00:00
giovanni eac671fa80 adjust calculate total price marketing ayam pullet 2026-05-12 19:55:06 +07:00
52 changed files with 3733 additions and 436 deletions
@@ -0,0 +1,5 @@
BEGIN;
DROP TABLE IF EXISTS daily_checklist_empty_kandangs;
COMMIT;
@@ -0,0 +1,60 @@
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;
@@ -0,0 +1,2 @@
ALTER TABLE customers DROP COLUMN bank_name;
ALTER TABLE suppliers DROP COLUMN bank_name;
@@ -0,0 +1,2 @@
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,6 +15,7 @@ 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"`
@@ -0,0 +1,27 @@
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"
}
+6 -5
View File
@@ -23,11 +23,12 @@ 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"`
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"`
}
type DailyChecklistPhase struct {
+1
View File
@@ -19,6 +19,7 @@ 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,6 +42,13 @@ 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 {
@@ -180,6 +187,17 @@ 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 {
@@ -241,5 +259,6 @@ func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.
TotalActivity: totalActivities,
Progress: progress,
DocumentURLs: documentURLs,
EmptyKandang: ToDailyChecklistEmptyKandangDTO(checklist.EmptyKandang),
}
}
+2 -1
View File
@@ -22,6 +22,7 @@ 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)
@@ -30,7 +31,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, phasesRepo, validate, documentSvc)
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, emptyKandangRepo, phasesRepo, validate, documentSvc)
userService := sUser.NewUserService(userRepo, validate)
DailyChecklistRoutes(router, userService, dailyChecklistService)
@@ -0,0 +1,98 @@
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,11 +44,12 @@ type DailyChecklistService interface {
}
type dailyChecklistService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.DailyChecklistRepository
PhaseRepo phaseRepo.PhasesRepository
DocumentSvc commonSvc.DocumentService
Log *logrus.Logger
Validate *validator.Validate
Repository repository.DailyChecklistRepository
EmptyKandangRepo repository.DailyChecklistEmptyKandangRepository
PhaseRepo phaseRepo.PhasesRepository
DocumentSvc commonSvc.DocumentService
}
type DailyChecklistDocument struct {
@@ -130,20 +131,24 @@ 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, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
func NewDailyChecklistService(repo repository.DailyChecklistRepository, emptyKandangRepo repository.DailyChecklistEmptyKandangRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
return &dailyChecklistService{
Log: utils.Log,
Validate: validate,
Repository: repo,
PhaseRepo: phaseRepo,
DocumentSvc: documentSvc,
Log: utils.Log,
Validate: validate,
Repository: repo,
EmptyKandangRepo: emptyKandangRepo,
PhaseRepo: phaseRepo,
DocumentSvc: documentSvc,
}
}
func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("Kandang")
return db.Preload("Kandang").Preload("EmptyKandang")
}
func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error {
@@ -529,6 +534,23 @@ 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 {
@@ -537,25 +559,39 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
}
if category == dailyChecklistCategoryEmptyKandang {
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, date); err != nil {
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 {
return err
}
if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil {
return err
}
} else {
conflictID := uint(0)
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date, category, status, &conflictID); err != nil {
if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, 0); err != nil {
return err
}
if conflictID > 0 {
targetID = conflictID
return nil
if err := s.validateDateNotInExistingEmptyKandangChecklist(tx, req.KandangId, date, 0); err != nil {
return err
}
}
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
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
})
if err != nil {
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
@@ -585,41 +621,118 @@ func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kan
return nil
}
func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error {
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)
}
var conflictCount int64
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 {
if err := q.Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist)
}
return 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 {
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 {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang)
}
if err := tx.Model(&entity.DailyChecklist{}).Where("id = ?", existing.Id).Updates(map[string]interface{}{
"category": newCategory,
"status": newStatus,
}).Error; err != nil {
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 {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap)
}
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
}
*conflictID = existing.Id
return nil
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 {
@@ -900,11 +1013,53 @@ 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",
@@ -928,12 +1083,63 @@ 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)
}
@@ -968,6 +1174,15 @@ 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) {
@@ -1695,92 +1910,43 @@ 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 emptyKandangRec struct {
type emptyRangeRec struct {
KandangID uint
Date time.Time
StartDate time.Time
EndDate time.Time
}
var emptyRecs []emptyKandangRec
var rangeRecs []emptyRangeRec
if err := s.Repository.DB().WithContext(c.Context()).
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)
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)
return err
}
emptyDaysByKandang := make(map[uint]map[int]struct{})
if len(emptyRecs) > 0 {
minEmptyDate := emptyRecs[0].Date
for _, rec := range emptyRecs[1:] {
if rec.Date.Before(minEmptyDate) {
minEmptyDate = rec.Date
}
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
}
type checklistDateRec struct {
KandangID uint
Date time.Time
if _, ok := emptyDaysByKandang[rec.KandangID]; !ok {
emptyDaysByKandang[rec.KandangID] = make(map[int]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{}{}
}
for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) {
emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{}
}
}
@@ -208,8 +208,18 @@ func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t *
Count(&activeInRange).Error; err != nil {
t.Fatalf("failed counting checklists in range: %v", err)
}
if activeInRange != 5 {
t.Fatalf("expected 5 active checklists created for range, got %d", activeInRange)
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)
}
}
@@ -304,6 +314,18 @@ 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)`,
@@ -316,7 +338,8 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.
}
repo := repository.NewDailyChecklistRepository(db)
svc := NewDailyChecklistService(repo, nil, validator.New(), nil)
emptyRepo := repository.NewDailyChecklistEmptyKandangRepository(db)
svc := NewDailyChecklistService(repo, emptyRepo, nil, validator.New(), nil)
return svc, db
}
@@ -9,7 +9,8 @@ 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"`
EmptyKandang bool `json:"empty_kandang"`
EmptyKandangEndDate string `json:"empty_kandang_end_date" validate:"omitempty"`
}
type Update struct {
@@ -65,6 +65,8 @@ 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,6 +98,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return db.
Preload("Expense").
Preload("Expense.Supplier").
Preload("Expense.Location").
Preload("Kandang").
Preload("Kandang.Location").
Preload("Nonstock").
@@ -177,10 +178,48 @@ 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("expense_realizations.created_at DESC").
Order(sortExpr + " " + order).
Find(&realizations).Error; err != nil {
return nil, 0, err
}
@@ -289,7 +289,40 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
like,
)
}
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
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")
}
})
if scopeErr != nil {
@@ -54,6 +54,8 @@ 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,6 +5,7 @@ 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"
@@ -13,6 +14,8 @@ import (
"github.com/gofiber/fiber/v2"
)
const transactionExcelExportFetchLimit = 99999999
type TransactionController struct {
TransactionService service.TransactionService
}
@@ -97,6 +100,8 @@ 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", ""),
}
@@ -105,6 +110,14 @@ 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
@@ -147,6 +160,32 @@ 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")
@@ -0,0 +1,307 @@
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,19 +72,26 @@ 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)
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
needsPartyJoin := params.Search != "" || params.SortBy == "customer_name"
needsBankJoin := params.Search != "" || params.SortBy == "bank"
if needsPartyJoin {
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
@@ -93,7 +100,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,
)
}
@@ -138,7 +145,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
db = db.Where("payment_date < ?", *endDate)
}
return applyTransactionSort(db, params.SortDate)
return applyTransactionSort(db, params.SortBy, params.SortOrder, params.SortDate)
})
if err != nil {
@@ -270,13 +277,39 @@ func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Tim
return startPtr, endPtr, nil
}
func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB {
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)
}
switch strings.ToLower(strings.TrimSpace(sortDate)) {
case "created_at":
return db.Order("created_at DESC").Order("payment_date DESC")
case "payment_date":
return db.Order("payment_date DESC").Order("created_at DESC")
return db.Order("payments.created_at DESC").Order("payments.payment_date DESC")
default:
return db.Order("payment_date DESC").Order("created_at DESC")
return db.Order("payments.payment_date DESC").Order("payments.created_at DESC")
}
}
@@ -10,13 +10,15 @@ 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,max=100,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,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,7 +2,6 @@ package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -153,7 +152,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), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil {
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), sumMarketingGrandTotal(item.SalesOrder)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil {
@@ -266,40 +265,6 @@ 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,6 +310,8 @@ 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")
}
@@ -540,9 +542,15 @@ 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)
@@ -628,6 +636,23 @@ 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,
approvalutils.ApprovalStep(latestApproval.StepNumber),
utils.MarketingStepPengajuan,
&action,
actorID,
nil)
@@ -770,15 +770,21 @@ 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 {
totalWeight, totalPrice := s.calculatePriceByMarketingType(
marketingType,
rp.Qty,
rp.AvgWeight,
rp.UnitPrice,
rp.Week,
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
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,
)
}
marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId,
@@ -821,7 +827,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(unitPrice*float64(*week)*qty*100) / 100
totalPrice = math.Round(totalWeight*unitPrice*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"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
}
@@ -26,6 +26,7 @@ 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,6 +14,7 @@ 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"`
@@ -28,6 +29,7 @@ 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"`
@@ -53,6 +55,7 @@ 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,
@@ -81,6 +84,7 @@ 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,6 +133,7 @@ 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,
}
@@ -193,6 +194,10 @@ 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,6 +8,7 @@ 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 {
@@ -18,6 +19,7 @@ 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,6 +26,7 @@ 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"`
@@ -66,6 +67,7 @@ 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,6 +160,7 @@ 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,
}
@@ -243,6 +244,10 @@ 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,6 +12,7 @@ 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"`
}
@@ -27,6 +28,7 @@ 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")),
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")),
StartDate: strings.TrimSpace(c.Query("start_date")),
EndDate: strings.TrimSpace(c.Query("end_date")),
FilterBy: strings.TrimSpace(c.Query("filter_by")),
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,7 +2,6 @@ package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -43,15 +42,13 @@ 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, grandTotals); err != nil {
if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases); err != nil {
return nil, err
}
if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{
@@ -80,9 +77,17 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
"F": 22,
"G": 22,
"H": 32,
"I": 18,
"J": 18,
"K": 24,
"I": 10,
"J": 12,
"K": 16,
"L": 16,
"M": 22,
"N": 12,
"O": 16,
"P": 16,
"Q": 18,
"R": 18,
"S": 24,
}
for col, width := range columnWidths {
@@ -99,17 +104,25 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"PR Number",
"PO Number",
"Tanggal PO",
"Tanggal Terima",
"Supplier",
"Lokasi",
"Gudang",
"Product",
"Status",
"Grand Total",
"Notes",
"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
}
for i, header := range headers {
@@ -137,34 +150,36 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
return err
}
return file.SetCellStyle(sheet, "A1", "K1", headerStyle)
return file.SetCellStyle(sheet, "A1", "S1", headerStyle)
}
func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase, grandTotals map[uint]float64) error {
func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase) 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, total); err != nil {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, &sumL, &sumP, &sumQ); err != nil {
return err
}
rowIdx++
continue
}
for it := range purchase.Items {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], total); err != nil {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], &sumL, &sumP, &sumQ); err != nil {
return err
}
rowIdx++
}
}
lastRow := rowIdx - 1
lastDataRow := rowIdx - 1
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
@@ -181,7 +196,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A2", "K"+strconv.Itoa(lastRow), dataStyle); err != nil {
if err := file.SetCellStyle(sheet, "A2", "S"+strconv.Itoa(lastDataRow), dataStyle); err != nil {
return err
}
@@ -200,14 +215,17 @@ 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 file.SetCellStyle(sheet, "J2", "J"+strconv.Itoa(lastRow), moneyStyle)
return addPurchaseExportSumRow(file, sheet, rowIdx, sumL, sumP, sumQ)
}
func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, grandTotal float64) error {
func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, sumL, sumP, sumQ *float64) error {
row := strconv.Itoa(rowIdx)
// Purchase-level columns (repeat across rows of the same purchase)
// Purchase-level columns (repeat for every item row of the same purchase)
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil {
return err
}
@@ -220,26 +238,40 @@ 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, "I"+row, formatPurchaseExportEntityStatus(purchase)); err != nil {
if err := file.SetCellValue(sheet, "R"+row, formatPurchaseExportEntityStatus(purchase)); err != nil {
return err
}
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 {
if err := file.SetCellValue(sheet, "S"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
return err
}
// Item-level columns
if item == nil {
for _, col := range []string{"D", "F", "G", "H"} {
for _, col := range []string{"D", "F", "G", "H", "J", "M"} {
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
}
@@ -252,20 +284,96 @@ 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 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
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
}
return result
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)
}
func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string {
@@ -296,6 +404,24 @@ 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 "-"
@@ -338,37 +464,3 @@ 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,9 +22,8 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
nil,
"catatan",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"),
buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""),
buildPurchaseItemForExportTest(11, "Pakan Starter", 500, 2, 1000000, "Location A", "kg"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350, 1, 350000, "Location B", "botol"),
},
),
buildPurchaseForExportTest(
@@ -37,7 +36,7 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
ptrApprovalAction(entity.ApprovalActionRejected),
"",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(21, "Obat X", 75000, ""),
buildPurchaseItemForExportTest(21, "Obat X", 75000, 1, 75000, "", ""),
},
),
})
@@ -51,16 +50,27 @@ 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": "Supplier",
"E1": "Lokasi",
"F1": "Status",
"G1": "Grand Total",
"H1": "Products",
"I1": "Notes",
"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",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(purchaseExportSheetName, cell)
@@ -72,24 +82,46 @@ 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, "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")
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, "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", "-")
// 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")
}
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
@@ -144,13 +176,20 @@ func buildPurchaseForExportTest(
}
}
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64, locationName string) entity.PurchaseItem {
func buildPurchaseItemForExportTest(productID uint, productName string, price, totalQty, totalPrice float64, locationName, uomName string) entity.PurchaseItem {
uomID := uint(0)
if uomName != "" {
uomID = productID + 2000
}
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},
},
}
+26 -10
View File
@@ -32,12 +32,15 @@ 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"`
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"`
}
type PurchaseDetailDTO struct {
@@ -69,6 +72,8 @@ 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"`
}
@@ -127,6 +132,8 @@ 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
@@ -173,15 +180,21 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
}
var (
poExpedition = make([]PoExpeditionDTO, 0)
location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO
receivedDate *time.Time
poExpedition = make([]PoExpeditionDTO, 0)
location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO
receivedDate *time.Time
productsTotal float64
expeditionTotal float64
)
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)
@@ -235,6 +248,9 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
LatestApproval: latestApproval,
ProductsTotal: productsTotal,
ExpeditionTotal: expeditionTotal,
GrandTotalAll: productsTotal + expeditionTotal,
}
}
@@ -145,33 +145,16 @@ 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())
}
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())
}
dateStart, dateEnd, err := parsePurchaseDateRangeForQuery(params.StartDate, params.EndDate, "date")
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)
@@ -187,23 +170,41 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = db.Where("supplier_id = ?", params.SupplierID)
}
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)
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 scope.Restrict {
@@ -261,7 +262,48 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
db = applyPurchaseSearchFilter(db, search)
return db.Order("created_at DESC").Order("purchases.id DESC")
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")
}
})
if err != nil {
@@ -2197,30 +2239,29 @@ func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []ent
return nil
}
func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) {
func parsePurchaseDateRangeForQuery(fromStr, toStr, fieldName 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("po_date_from must use format YYYY-MM-DD")
return nil, nil, errors.New(fieldName + "_from must use format YYYY-MM-DD")
}
fromValue := parsed
fromPtr = &fromValue
fromPtr = &parsed
}
if strings.TrimSpace(toStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(toStr))
if err != nil {
return nil, nil, errors.New("po_date_to must use format YYYY-MM-DD")
return nil, nil, errors.New(fieldName + "_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("po_date_from must be earlier than po_date_to")
return nil, nil, errors.New(fieldName + "_from must be earlier than " + fieldName + "_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"`
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"`
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"`
Search string `query:"search" validate:"omitempty,max=100"`
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"`
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"`
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"`
}
@@ -51,6 +51,8 @@ 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())
@@ -362,6 +364,7 @@ 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", ""),
}
@@ -389,6 +392,13 @@ 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{}
@@ -459,6 +469,8 @@ 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", ""),
}
@@ -473,6 +485,13 @@ 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).
@@ -500,6 +519,83 @@ 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 == "" {
@@ -0,0 +1,576 @@
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")
}
@@ -0,0 +1,452 @@
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,7 +2,6 @@ package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -197,9 +196,9 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte
item.Qty,
item.AverageWeightKg,
item.TotalWeightKg,
formatMarketingRupiah(item.SalesPricePerKg),
formatMarketingRupiah(item.HppPricePerKg),
formatMarketingRupiah(item.SalesAmount),
item.SalesPricePerKg,
item.HppPricePerKg,
item.SalesAmount,
}
for colIdx, val := range values {
@@ -229,13 +228,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, formatMarketingRupiah(summary.AverageSalesPrice)); err != nil {
if err := file.SetCellValue(sheet, "O"+totalRow, summary.AverageSalesPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+totalRow, formatMarketingRupiah(summary.TotalHppPricePerKg)); err != nil {
if err := file.SetCellValue(sheet, "P"+totalRow, summary.TotalHppPricePerKg); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+totalRow, formatMarketingRupiah(float64(summary.TotalSalesAmount))); err != nil {
if err := file.SetCellValue(sheet, "Q"+totalRow, float64(summary.TotalSalesAmount)); err != nil {
return err
}
}
@@ -333,30 +332,3 @@ 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()
}
@@ -0,0 +1,71 @@
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,6 +6,7 @@ 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"
)
@@ -48,6 +49,7 @@ 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"`
@@ -133,6 +135,15 @@ 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 {
@@ -142,6 +153,7 @@ func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNo
return RepportExpenseListDTO{
RepportExpenseBaseDTO: baseDTO,
Location: location,
Kandang: kandang,
Pengajuan: ToRepportExpensePengajuanDTO(ns),
Realisasi: realisasi,
+2
View File
@@ -40,6 +40,7 @@ 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)
@@ -66,6 +67,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
hppPerKandangRepository,
productionResultRepository,
customerPaymentRepository,
balanceMonitoringRepository,
customerRepository,
standardGrowthDetailRepository,
productionStandardDetailRepository,
@@ -0,0 +1,518 @@
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) ([]uint, int64, error)
GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint, sortBy, sortOrder string) ([]uint, int64, error)
}
type customerPaymentRepositoryImpl struct {
@@ -146,21 +146,34 @@ func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context.
return result.Nominal, nil
}
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 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, 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)
}
@@ -170,28 +183,14 @@ func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx conte
return nil, 0, err
}
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")
query := r.db.WithContext(ctx).Table(unionSQL).Select("customer_id")
if len(allowedCustomerIDs) > 0 {
query = query.Where("customer_id IN ?", allowedCustomerIDs)
}
var customerIDs []uint
err := query.
Order("customer_id ASC").
Order(resolveCustomerPaymentSortClause(sortBy, sortOrder)).
Limit(limit).
Offset(offset).
Pluck("customer_id", &customerIDs).
@@ -15,13 +15,16 @@ 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 {
@@ -52,6 +55,19 @@ 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":
@@ -129,15 +145,24 @@ func (r *debtSupplierRepositoryImpl) GetSuppliersWithPurchases(ctx context.Conte
offset = 0
}
var supplierIDs []uint
if err := query.
Select("suppliers.id").
Order("suppliers.id ASC").
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)).
Offset(offset).
Limit(limit).
Pluck("suppliers.id", &supplierIDs).Error; err != nil {
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
@@ -146,6 +171,7 @@ 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
}
@@ -467,3 +493,218 @@ 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,4 +26,5 @@ 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,6 +52,7 @@ 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
}
@@ -74,6 +75,7 @@ type repportService struct {
HppPerKandangRepo repportRepo.HppPerKandangRepository
ProductionResultRepo repportRepo.ProductionResultRepository
CustomerPaymentRepo repportRepo.CustomerPaymentRepository
BalanceMonitoringRepo repportRepo.BalanceMonitoringRepository
CustomerRepo customerRepo.CustomerRepository
StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository
ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository
@@ -106,6 +108,7 @@ func NewRepportService(
hppPerKandangRepo repportRepo.HppPerKandangRepository,
productionResultRepo repportRepo.ProductionResultRepository,
customerPaymentRepo repportRepo.CustomerPaymentRepository,
balanceMonitoringRepo repportRepo.BalanceMonitoringRepository,
customerRepo customerRepo.CustomerRepository,
standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository,
productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository,
@@ -129,6 +132,7 @@ func NewRepportService(
HppPerKandangRepo: hppPerKandangRepo,
ProductionResultRepo: productionResultRepo,
CustomerPaymentRepo: customerPaymentRepo,
BalanceMonitoringRepo: balanceMonitoringRepo,
CustomerRepo: customerRepo,
StandardGrowthDetailRepo: standardGrowthDetailRepo,
ProductionStandardDetailRepo: productionStandardDetailRepo,
@@ -1029,6 +1033,13 @@ 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
}
@@ -1083,7 +1094,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)
customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset, allowedCustomerIDs, params.SortBy, params.SortOrder)
if err != nil {
return nil, 0, err
}
@@ -1755,6 +1766,12 @@ 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
@@ -1765,7 +1782,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
offset = 0
}
suppliers, totalSuppliers, err := s.DebtSupplierRepo.GetSuppliersWithPurchases(c.Context(), offset, params.Limit, params)
suppliers, totalSuppliers, err := s.DebtSupplierRepo.GetSuppliersWithDebts(c.Context(), offset, params.Limit, params)
if err != nil {
return nil, 0, err
}
@@ -1790,11 +1807,21 @@ 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)
@@ -1810,6 +1837,11 @@ 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
@@ -1830,10 +1862,10 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
CountTotals bool
}
type debtSupplierAllocation struct {
RowIndex int
SortTime time.Time
Amount float64
Purchase entity.Purchase
RowIndex int
SortTime time.Time
Amount float64
CalcAging func(endDate time.Time) int
}
type paymentAllocation struct {
Date time.Time
@@ -1846,7 +1878,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
continue
}
initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID])
initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] - initialExpenseTotals[supplierID])
items := purchasesBySupplier[supplierID]
paymentItems := paymentsBySupplier[supplierID]
total := dto.DebtSupplierTotalDTO{}
@@ -1864,11 +1896,32 @@ 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,
Purchase: purchase,
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) },
})
}
@@ -1933,7 +1986,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 = calculateDebtSupplierAging(allocation.Purchase, pay.Date, location)
combinedRows[allocation.RowIndex].Row.Aging = allocation.CalcAging(pay.Date)
purchaseIndex++
}
}
@@ -2207,6 +2260,62 @@ 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())
@@ -2880,3 +2989,163 @@ 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,6 +13,8 @@ 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:"-"`
}
@@ -58,6 +60,7 @@ 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:"-"`
@@ -108,6 +111,22 @@ 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:"-"`
}