Merge branch 'development' into 'production'

Development

See merge request mbugroup/lti-api!494
This commit is contained in:
Adnan Zahir
2026-04-29 12:53:02 +07:00
50 changed files with 1315 additions and 81 deletions
@@ -132,9 +132,9 @@ WITH duplicates AS (
pw.project_flock_kandang_id,
pw.id,
pw.qty,
MIN(pw.id) OVER (PARTITION BY pw.warehouse_id, pw.product_id, pw.project_flock_kandang_id) AS survivor_id,
COUNT(*) OVER (PARTITION BY pw.warehouse_id, pw.product_id, pw.project_flock_kandang_id) AS duplicate_count,
SUM(pw.qty) OVER (PARTITION BY pw.warehouse_id, pw.product_id, pw.project_flock_kandang_id) AS total_qty
MIN(pw.id) OVER (PARTITION BY pw.warehouse_id, pw.product_id) AS survivor_id,
COUNT(*) OVER (PARTITION BY pw.warehouse_id, pw.product_id) AS duplicate_count,
SUM(pw.qty) OVER (PARTITION BY pw.warehouse_id, pw.product_id) AS total_qty
FROM product_warehouses pw
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products p ON p.id = pw.product_id
@@ -149,7 +149,7 @@ SELECT
product_name,
area_name,
location_name,
project_flock_kandang_id,
(SELECT project_flock_kandang_id FROM duplicates d2 WHERE d2.id = survivor_id LIMIT 1) AS project_flock_kandang_id,
survivor_id,
(SELECT qty FROM duplicates d2 WHERE d2.id = survivor_id LIMIT 1) AS survivor_qty,
duplicate_count - 1 AS absorbed_count,
@@ -157,7 +157,7 @@ SELECT
STRING_AGG(id::text, ', ' ORDER BY id::text) FILTER (WHERE id <> survivor_id) AS absorbed_ids
FROM duplicates
WHERE duplicate_count > 1
GROUP BY warehouse_id, warehouse_name, product_id, product_name, area_name, location_name, project_flock_kandang_id, survivor_id, total_qty, duplicate_count
GROUP BY warehouse_id, warehouse_name, product_id, product_name, area_name, location_name, survivor_id, total_qty, duplicate_count
ORDER BY area_name, location_name, warehouse_name, product_name
`, filters)
@@ -244,6 +244,19 @@ func applyConsolidation(ctx context.Context, db *gorm.DB, groups []duplicateGrou
return fmt.Errorf("update survivor qty: %w", res.Error)
}
// Clear project_flock_kandang_id for LOKASI warehouse survivors
if err := tx.WithContext(ctx).Exec(`
UPDATE product_warehouses pw
SET project_flock_kandang_id = NULL
FROM warehouses w
WHERE pw.warehouse_id = w.id
AND pw.id = ?
AND UPPER(w.type) = 'LOKASI'
AND pw.project_flock_kandang_id IS NOT NULL
`, group.SurvivorID).Error; err != nil {
return fmt.Errorf("clear project_flock_kandang_id survivor %d: %w", group.SurvivorID, err)
}
// Delete absorbed product_warehouses
res = tx.WithContext(ctx).
Table("product_warehouses").
+1
View File
@@ -52,6 +52,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-pdf/fpdf v0.9.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
+2
View File
@@ -77,6 +77,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -0,0 +1 @@
DROP TABLE IF EXISTS system_settings;
@@ -0,0 +1,11 @@
CREATE TABLE system_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO system_settings (key, value, description) VALUES
('allow_negative_pakan_ovk', 'false',
'Izinkan pencatatan penggunaan PAKAN & OVK negatif (mode migrasi): membuka semua produk PAKAN & OVK meskipun belum ada pembelian di sistem');
+17
View File
@@ -0,0 +1,17 @@
package entities
import "time"
const SystemSettingKeyAllowNegativePakanOVK = "allow_negative_pakan_ovk"
type SystemSetting struct {
Key string `gorm:"column:key;primaryKey" json:"key"`
Value string `gorm:"column:value;not null;default:''" json:"value"`
Description string `gorm:"column:description" json:"description"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
}
func (SystemSetting) TableName() string {
return "system_settings"
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
type Warehouse struct {
Id uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(50);not null"`
Type string `gorm:"not null"`
Type string `gorm:"column:type;not null"`
AreaId uint `gorm:"not null"`
LocationId *uint
KandangId *uint
+4
View File
@@ -4,6 +4,10 @@ const (
P_DashboardGetAll = "lti.dashboard.list"
)
const (
P_SystemSettingUpdate = "lti.system_settings.update"
)
// project-flock
const (
P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing"
@@ -10,7 +10,7 @@ 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"`
ProjectStatus *int `query:"project_status" validate:"omitempty,oneof=1 2"`
LocationID *uint `query:"location_id" validate:"omitempty,gt=0"`
@@ -24,7 +24,7 @@ const (
type ClosingSapronakQuery struct {
Type string `query:"type" validate:"required,oneof=incoming outgoing"`
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"`
KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"`
}
@@ -33,6 +33,7 @@ type ProjectFlockRelationDTO struct {
type WarehouseRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Location *LocationRelationDTO `json:"location,omitempty"`
}
@@ -113,6 +114,7 @@ func ToWarehouseRelationDTO(e *entity.Warehouse) *WarehouseRelationDTO {
return &WarehouseRelationDTO{
Id: e.Id,
Name: e.Name,
Type: e.Type,
Location: ToLocationRelationDTO(e.Location),
}
}
@@ -16,7 +16,7 @@ type Create struct {
type Query struct {
Page int `query:"page" validate:"omitempty,min=1"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,min=1"`
ProductID uint `query:"product_id" validate:"omitempty,min=0"`
WarehouseID uint `query:"warehouse_id" validate:"omitempty,min=0"`
TransactionType string `query:"transaction_type" validate:"omitempty,max=100"`
@@ -10,7 +10,7 @@ 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"`
ProductCategoryID uint `query:"product_category_id" validate:"omitempty"`
}
@@ -79,8 +79,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
if e.Product.Id != 0 {
product := productDTO.ToProductRelationDTO(e.Product)
// Create a copy with flock name appended if exists
if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 {
// Append flock name only for KANDANG-type warehouses.
// Farm-level (LOKASI) warehouses are shared across flocks — attaching a flock
// label there creates duplicates and is misleading.
if e.ProjectFlockKandang != nil &&
e.ProjectFlockKandang.ProjectFlock.Id != 0 &&
e.Warehouse.Type == "KANDANG" {
productCopy := product
productCopy.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")"
dto.Product = &productCopy
@@ -7,7 +7,6 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
@@ -166,42 +165,16 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s
return db
}
fallbackCategoryCodes := utils.LegacyProductCategoryCodesForFlags(flags)
db = db.
Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id").
Joins("LEFT JOIN product_categories pc_flag ON pc_flag.id = p_flag.product_category_id")
actualFlagFilter := `
EXISTS (
SELECT 1
FROM flags f_flag
WHERE f_flag.flagable_id = p_flag.id
AND f_flag.flagable_type = ?
AND f_flag.name IN ?
)
`
if len(fallbackCategoryCodes) == 0 {
return db.Where(actualFlagFilter, entity.FlagableTypeProduct, flags).Distinct()
}
return db.
Where(
`(`+actualFlagFilter+`) OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_id = p_flag.id
AND f_any.flagable_type = ?
)
AND pc_flag.code IN ?
)`,
entity.FlagableTypeProduct,
flags,
entity.FlagableTypeProduct,
fallbackCategoryCodes,
).
Where(`
EXISTS (
SELECT 1
FROM flags f_flag
WHERE f_flag.flagable_id = product_warehouses.product_id
AND f_flag.flagable_type = ?
AND f_flag.name IN ?
)
`, entity.FlagableTypeProduct, flags).
Distinct()
}
@@ -117,7 +117,7 @@ func insertProductWarehouseTestFixtures(t *testing.T, db *gorm.DB) {
}
}
func TestApplyFlagsFilterIncludesLegacyCategoryFallback(t *testing.T) {
func TestApplyFlagsFilterOnlyIncludesFlaggedProducts(t *testing.T) {
db := setupProductWarehouseFlagFilterTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
@@ -131,12 +131,14 @@ func TestApplyFlagsFilterIncludesLegacyCategoryFallback(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("expected flagged and legacy RAW rows to match, got %v", ids)
// Only PW 1 (product 10, flagged PAKAN) should match.
// PW 2 (product 20, no flags, RAW category) must not appear — legacy fallback removed.
if len(ids) != 1 || ids[0] != 1 {
t.Fatalf("expected only flagged row to match, got %v", ids)
}
}
func TestApplyFlagsFilterDoesNotFallbackWhenProductAlreadyHasDifferentFlags(t *testing.T) {
func TestApplyFlagsFilterExcludesWrongFlaggedProducts(t *testing.T) {
db := setupProductWarehouseFlagFilterTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
@@ -150,8 +152,9 @@ func TestApplyFlagsFilterDoesNotFallbackWhenProductAlreadyHasDifferentFlags(t *t
t.Fatalf("unexpected error: %v", err)
}
// PW 3 belongs to an OVK-flagged product — must not appear when filtering for PAKAN.
if len(ids) != 0 {
t.Fatalf("expected OVK-flagged product not to match PAKAN fallback, got %v", ids)
t.Fatalf("expected OVK-flagged product not to match PAKAN filter, got %v", ids)
}
}
@@ -14,7 +14,7 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
@@ -6,7 +6,7 @@ type Create struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
ProductID uint `query:"product_id" validate:"omitempty"`
WarehouseID uint `query:"warehouse_id" validate:"omitempty"`
@@ -23,7 +23,7 @@ type DeliveryOrderUpdate struct {
type DeliveryOrderQuery 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=100"`
ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"`
Status string `query:"status" validate:"omitempty,max=50"`
@@ -10,6 +10,6 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -16,6 +16,6 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -14,6 +14,6 @@ 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"`
}
@@ -22,7 +22,7 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
HasMarketing *bool `query:"has_marketing" validate:"omitempty"`
}
@@ -18,6 +18,6 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -10,6 +10,6 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -16,7 +16,7 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"`
}
@@ -15,7 +15,7 @@ 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"`
PhaseIDs string `query:"phase_ids" validate:"omitempty"`
}
@@ -11,7 +11,7 @@ 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"`
Category *string `query:"category" validate:"omitempty"`
}
@@ -12,6 +12,6 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -36,7 +36,7 @@ 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"`
ProjectCategory string `query:"project_category" validate:"omitempty,oneof=GROWING LAYING"`
}
@@ -264,6 +264,18 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
db = db.Where("NOT "+existsQuery, entity.FlagableTypeProduct, depletionProductFlags)
}
}
if strings.TrimSpace(params.Flags) != "" {
cleanFlags := utils.ParseFlags(params.Flags)
if len(cleanFlags) > 0 {
db = db.Where(`
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = ?
AND f.flagable_id = products.id
AND UPPER(f.name) IN ?
)`, entity.FlagableTypeProduct, cleanFlags)
}
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
@@ -46,4 +46,5 @@ type Query struct {
ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"`
IsDepletion *bool `query:"is_depletion" validate:"omitempty"`
IncludeAll *bool `query:"include_all" validate:"omitempty"`
Flags string `query:"flags" validate:"omitempty,max=200"`
}
@@ -10,6 +10,6 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -18,7 +18,7 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
AreaId int `query:"area_id" validate:"omitempty,number,gt=0"`
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
@@ -120,6 +120,11 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF
locationSummary = &mapped
}
// Jika period tidak di-pass secara eksplisit (0), derive dari KandangHistory
if period == 0 && len(e.KandangHistory) > 0 {
period = e.KandangHistory[0].Period
}
latestApproval := defaultProjectFlockLatestApproval(e)
if e.LatestApproval != nil {
snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval)
@@ -374,7 +374,32 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
projectRepo := repository.NewProjectflockRepository(dbTransaction)
generatedName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, 1, nil)
var periods map[uint]int
if req.Periode != nil {
// Pakai periode yang diminta untuk semua kandang
periods = make(map[uint]int, len(kandangIDs))
for _, kandangID := range kandangIDs {
periods[kandangID] = *req.Periode
}
} else {
periods, err = projectRepo.GetNextPeriodsForKandangs(c.Context(), kandangIDs)
if err != nil {
return err
}
}
startPeriod := 1
if req.Periode != nil {
startPeriod = *req.Periode
} else {
for _, p := range periods {
if p > startPeriod {
startPeriod = p
}
}
}
generatedName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, startPeriod, nil)
if err != nil {
return err
}
@@ -384,10 +409,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
return err
}
periods, err := projectRepo.GetNextPeriodsForKandangs(c.Context(), kandangIDs)
if err != nil {
return err
}
if err := s.attachKandangs(c.Context(), dbTransaction, createBody.Id, kandangIDs, periods); err != nil {
return err
}
@@ -1225,12 +1246,34 @@ func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, id
}
}
// Hitung newFlockName sebelum membuka transaksi (fast-path conflict check)
var newFlockName string
if req.Periode != nil {
lastSpace := strings.LastIndex(existing.FlockName, " ")
if lastSpace < 0 {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Format flock name tidak valid")
}
baseName := strings.TrimSpace(existing.FlockName[:lastSpace])
newFlockName = fmt.Sprintf("%s %03d", baseName, *req.Periode)
taken, err := s.Repository.ExistsByFlockName(c.Context(), newFlockName, &id)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa nama flock")
}
if taken {
return nil, fiber.NewError(fiber.StatusConflict,
fmt.Sprintf("Nama flock '%s' sudah digunakan oleh project flock lain", newFlockName))
}
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
var period int = 1
if len(existing.KandangHistory) > 0 {
if req.Periode != nil {
period = *req.Periode
} else if len(existing.KandangHistory) > 0 {
period = existing.KandangHistory[0].Period
}
@@ -1242,6 +1285,40 @@ func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, id
if err := s.attachKandangs(c.Context(), dbTransaction, existing.Id, kandangIDs, periods); err != nil {
return err
}
// Update period pada SEMUA row project_flock_kandangs milik flock ini.
// attachKandangs hanya INSERT baris baru dan melewati yang sudah ada,
// sehingga period pada baris lama tidak terupdate tanpa langkah ini.
if req.Periode != nil {
if err := dbTransaction.WithContext(c.Context()).
Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", existing.Id).
Update("period", *req.Periode).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui periode kandang")
}
}
// Update flock_name sesuai periode baru.
if req.Periode != nil {
projectRepoTx := repository.NewProjectflockRepository(dbTransaction)
// Re-check di dalam transaksi untuk cegah race condition.
taken, err := projectRepoTx.ExistsByFlockName(c.Context(), newFlockName, &existing.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa nama flock")
}
if taken {
return fiber.NewError(fiber.StatusConflict,
fmt.Sprintf("Nama flock '%s' sudah digunakan oleh project flock lain", newFlockName))
}
if err := projectRepoTx.PatchOne(c.Context(), existing.Id, map[string]any{
"flock_name": newFlockName,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui nama flock")
}
}
if err := s.UpsertProjectBudget(c.Context(), dbTransaction, existing.Id, req.ProjectBudgets); err != nil {
return err
}
@@ -6,6 +6,7 @@ type Create struct {
Category string `json:"category" validate:"required_strict"`
ProductionStandardId uint `json:"production_standard_id" validate:"required_strict,number,gt=0"`
LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"`
Periode *int `json:"periode" validate:"omitempty,gt=0"`
KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"`
ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"`
}
@@ -40,4 +41,5 @@ type ProjectBudget struct {
type Resubmit struct {
KandangIds []uint `json:"kandang_ids" validate:"required_strict,min=1,dive,gt=0"`
ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required_strict,min=1,dive"`
Periode *int `json:"periode" validate:"omitempty,gt=0"`
}
@@ -27,6 +27,7 @@ import (
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rSystemSettings "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -159,6 +160,8 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
validate,
)
systemSettingRepo := rSystemSettings.NewSystemSettingRepository(db)
recordingService := sRecording.NewRecordingService(
recordingRepo,
projectFlockKandangRepo,
@@ -174,6 +177,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
transferLayingRepo,
transferLayingService,
validate,
systemSettingRepo,
)
userService := sUser.NewUserService(userRepo, validate)
@@ -24,6 +24,7 @@ import (
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rSystemSettings "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
fifo "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -63,6 +64,7 @@ type recordingService struct {
TransferLayingSvc sTransferLaying.TransferLayingService
FifoStockV2Svc commonSvc.FifoStockV2Service
StockLogRepo rStockLogs.StockLogRepository
SystemSettingRepo rSystemSettings.SystemSettingRepository
}
func NewRecordingService(
@@ -80,6 +82,7 @@ func NewRecordingService(
transferLayingRepo rTransferLaying.TransferLayingRepository,
transferLayingSvc sTransferLaying.TransferLayingService,
validate *validator.Validate,
systemSettingRepo rSystemSettings.SystemSettingRepository,
) RecordingService {
return &recordingService{
Log: utils.Log,
@@ -97,6 +100,7 @@ func NewRecordingService(
TransferLayingSvc: transferLayingSvc,
FifoStockV2Svc: fifoStockV2Svc,
StockLogRepo: stockLogRepo,
SystemSettingRepo: systemSettingRepo,
}
}
@@ -390,6 +394,11 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return nil, err
}
req.Stocks, err = s.resolveStocksForMigrationMode(ctx, req.Stocks, pfk, actorID)
if err != nil {
return nil, err
}
if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil {
return nil, err
}
@@ -635,6 +644,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if match {
hasStockChanges = false
} else {
req.Stocks, err = s.resolveStocksForMigrationMode(ctx, req.Stocks, pfkForRoute, actorID)
if err != nil {
return err
}
if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil {
return err
}
@@ -2205,6 +2218,104 @@ func (s *recordingService) validateWarehouseIDs(
return nil
}
// resolveMigrationWarehouseID returns the warehouse ID to use when auto-creating product_warehouse
// entries in migration mode. Prefers the farm-level (LOKASI) warehouse of the kandang's location;
// falls back to the kandang-level (KANDANG) warehouse if no LOKASI warehouse exists.
func (s *recordingService) resolveMigrationWarehouseID(ctx context.Context, kandangID uint) (uint, error) {
type row struct {
ID uint `gorm:"column:id"`
LocationID *uint `gorm:"column:location_id"`
}
db := s.ProductWarehouseRepo.DB().WithContext(ctx)
// Step 1: get the kandang's location_id
var kandang row
if err := db.Table("kandangs").Select("id, location_id").
Where("id = ? AND deleted_at IS NULL", kandangID).
Limit(1).Take(&kandang).Error; err != nil {
return 0, fmt.Errorf("kandang %d tidak ditemukan: %w", kandangID, err)
}
// Step 2: prefer a LOKASI-type warehouse at the kandang's location (farm-level)
if kandang.LocationID != nil && *kandang.LocationID != 0 {
var lokasi row
err := db.Table("warehouses").Select("id").
Where("type = 'LOKASI' AND location_id = ? AND deleted_at IS NULL", *kandang.LocationID).
Limit(1).Take(&lokasi).Error
if err == nil {
return lokasi.ID, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fmt.Errorf("gagal mencari warehouse LOKASI untuk location %d: %w", *kandang.LocationID, err)
}
}
// Step 3: fall back to the KANDANG-type warehouse
var kandangWH row
if err := db.Table("warehouses").Select("id").
Where("type = 'KANDANG' AND kandang_id = ? AND deleted_at IS NULL", kandangID).
Limit(1).Take(&kandangWH).Error; err != nil {
return 0, fmt.Errorf("warehouse tidak ditemukan untuk kandang %d: %w", kandangID, err)
}
return kandangWH.ID, nil
}
// resolveStocksForMigrationMode handles stocks that use product_id (instead of product_warehouse_id)
// when migration mode (allow_negative_pakan_ovk) is enabled. It finds or creates product_warehouse
// entries in the farm-level (LOKASI) warehouse, falling back to the kandang warehouse, then sets
// the resolved product_warehouse_id on each stock item.
func (s *recordingService) resolveStocksForMigrationMode(
ctx context.Context,
stocks []validation.Stock,
pfk *entity.ProjectFlockKandang,
actorID uint,
) ([]validation.Stock, error) {
if s.SystemSettingRepo == nil {
return stocks, nil
}
allowed, err := s.SystemSettingRepo.GetAllowNegativePakanOVK(ctx)
if err != nil {
s.Log.Errorf("Failed to read allow_negative_pakan_ovk setting: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membaca konfigurasi sistem")
}
if !allowed {
return stocks, nil
}
var warehouseID uint
warehouseResolved := false
result := make([]validation.Stock, len(stocks))
copy(result, stocks)
for i := range result {
stock := &result[i]
if stock.ProductId == nil || stock.ProductWarehouseId != 0 {
continue
}
// Resolve target warehouse lazily on first need (same for all stocks in one request)
if !warehouseResolved {
if pfk == nil || pfk.KandangId == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Kandang tidak ditemukan untuk mode migrasi")
}
warehouseID, err = s.resolveMigrationWarehouseID(ctx, pfk.KandangId)
if err != nil {
s.Log.Errorf("Failed to resolve migration warehouse for kandang %d: %+v", pfk.KandangId, err)
return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse tidak ditemukan untuk mode migrasi")
}
warehouseResolved = true
}
pwID, err := s.ProductWarehouseRepo.EnsureProductWarehouse(ctx, *stock.ProductId, warehouseID, nil, actorID)
if err != nil {
s.Log.Errorf("Failed to ensure product warehouse for product %d in warehouse %d: %+v", *stock.ProductId, warehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menyiapkan product warehouse untuk produk %d", *stock.ProductId))
}
stock.ProductWarehouseId = pwID
}
return result, nil
}
func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlockKandangID uint, recordTime time.Time) (int, error) {
if projectFlockKandangID == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
@@ -4,7 +4,8 @@ import "time"
type (
Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
ProductWarehouseId uint `json:"product_warehouse_id" validate:"omitempty,number,min=1"`
ProductId *uint `json:"product_id,omitempty" validate:"omitempty,number,min=1"`
Qty float64 `json:"qty" validate:"required,gte=0"`
}
@@ -67,7 +67,7 @@ type UpdatePoDateRequest struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"`
AreaID uint `query:"area_id" validate:"omitempty,gt=0"`
LocationID uint `query:"location_id" validate:"omitempty,gt=0"`
@@ -246,6 +246,16 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error {
if err != nil {
return err
}
if isMarketingExcelExportRequest(ctx) {
return exportMarketingReportExcel(ctx, result)
}
if isMarketingPdfExportRequest(ctx) {
meta := buildMarketingPdfMeta(query.StartDate, query.EndDate)
return exportMarketingReportPdf(ctx, result, meta)
}
total := dto.ToSummaryFromDTOItems(result)
return ctx.Status(fiber.StatusOK).
@@ -0,0 +1,271 @@
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"
)
const marketingReportExportSheetName = "Laporan Marketing Harian"
func isMarketingExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func exportMarketingReportExcel(c *fiber.Ctx, items []dto.RepportMarketingItemDTO) error {
content, err := buildMarketingReportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan_marketing_harian_%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 buildMarketingReportWorkbook(items []dto.RepportMarketingItemDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != marketingReportExportSheetName {
if err := file.SetSheetName(defaultSheet, marketingReportExportSheetName); err != nil {
return nil, err
}
}
if err := setMarketingReportColumns(file); err != nil {
return nil, err
}
if err := setMarketingReportHeaders(file); err != nil {
return nil, err
}
if err := setMarketingReportRows(file, items); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setMarketingReportColumns(file *excelize.File) error {
columnWidths := map[string]float64{
"A": 10,
"B": 15,
"C": 18,
"D": 10,
"E": 25,
"F": 25,
"G": 15,
"H": 20,
"I": 15,
"J": 15,
"K": 20,
"L": 12,
"M": 20,
"N": 18,
"O": 18,
"P": 15,
"Q": 20,
"R": 20,
}
sheet := marketingReportExportSheetName
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return nil
}
func setMarketingReportHeaders(file *excelize.File) error {
sheet := marketingReportExportSheetName
headers := []string{
"No",
"Tanggal Jual",
"Tanggal Realisasi",
"Aging",
"Gudang Fisik",
"Pelanggan",
"No. DO",
"Sales/Marketing",
"No. Polisi",
"Marketing Type",
"Produk",
"Kuantitas",
"Bobot Rata-Rata (Kg)",
"Bobot Total (Kg)",
"Harga Jual (Rp)",
"HPP (Rp)",
"HPP Amount (Rp)",
"Total (Rp)",
}
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
}
}
return nil
}
func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingItemDTO) error {
sheet := marketingReportExportSheetName
summary := dto.ToSummaryFromDTOItems(items)
for idx, item := range items {
row := strconv.Itoa(idx + 2)
warehouseName := "-"
if item.Warehouse != nil {
warehouseName = safeMarketingExportText(item.Warehouse.Name)
}
customerName := "-"
if item.Customer != nil {
customerName = safeMarketingExportText(item.Customer.Name)
}
salesName := "-"
if item.Sales != nil {
salesName = safeMarketingExportText(item.Sales.Name)
}
productName := "-"
if item.Product != nil {
productName = safeMarketingExportText(item.Product.Name)
}
agingText := fmt.Sprintf("%d hari", item.AgingDays)
values := []interface{}{
idx + 1,
formatMarketingDate(item.SoDate),
formatMarketingDate(item.RealizationDate),
agingText,
warehouseName,
customerName,
safeMarketingExportText(item.DoNumber),
salesName,
safeMarketingExportText(item.VehicleNumber),
safeMarketingExportText(item.MarketingType),
productName,
item.Qty,
item.AverageWeightKg,
item.TotalWeightKg,
formatMarketingRupiah(item.SalesPricePerKg),
formatMarketingRupiah(item.HppPricePerKg),
formatMarketingRupiah(item.HppAmount),
formatMarketingRupiah(item.SalesAmount),
}
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
}
}
}
// Baris TOTAL
totalRow := strconv.Itoa(len(items) + 2)
if err := file.SetCellValue(sheet, "A"+totalRow, "TOTAL"); err != nil {
return err
}
if summary != nil {
if err := file.SetCellValue(sheet, "L"+totalRow, summary.TotalQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+totalRow, summary.AverageWeightKg); err != nil {
return err
}
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 {
return err
}
if err := file.SetCellValue(sheet, "P"+totalRow, formatMarketingRupiah(summary.TotalHppPricePerKg)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+totalRow, formatMarketingRupiah(float64(summary.TotalHppAmount))); err != nil {
return err
}
if err := file.SetCellValue(sheet, "R"+totalRow, formatMarketingRupiah(float64(summary.TotalSalesAmount))); err != nil {
return err
}
}
return nil
}
func formatMarketingDate(t time.Time) string {
if t.IsZero() {
return "-"
}
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(location)
}
return t.Format("02 Jan 2006")
}
func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
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,510 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/go-pdf/fpdf"
"github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
)
// ---------------------------------------------------------------------------
// Trigger
// ---------------------------------------------------------------------------
func isMarketingPdfExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "pdf")
}
// ---------------------------------------------------------------------------
// HTTP handler
// ---------------------------------------------------------------------------
func exportMarketingReportPdf(c *fiber.Ctx, items []dto.RepportMarketingItemDTO, query marketingPdfQueryMeta) error {
content, err := buildMarketingReportPdf(items, query)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate pdf file")
}
filename := fmt.Sprintf("laporan_marketing_harian_%s.pdf", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/pdf")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
// marketingPdfQueryMeta holds display metadata for the PDF header.
type marketingPdfQueryMeta struct {
StartDate string // e.g. "01 April 2026"
EndDate string // e.g. "29 April 2026"
PrintedAt time.Time
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
type pdfColumn struct {
header string
width float64 // mm
align string // "L", "C", "R"
}
var marketingPdfColumns = []pdfColumn{
{"No", 6, "C"},
{"Tanggal Sales Order", 16, "C"},
{"Tanggal Delivery Order", 16, "C"},
{"Aging\n(Hari)", 9, "C"},
{"Gudang Fisik", 20, "L"},
{"Pelanggan", 20, "L"},
{"Sales", 18, "L"},
{"Produk", 16, "L"},
{"Nomor DO", 14, "C"},
{"Nomor Polisi", 14, "C"},
{"Tipe\nMarketing", 14, "C"},
{"Quantity", 13, "R"},
{"Rata-Rata\n(Kg)", 13, "R"},
{"Total Berat\n(Kg)", 14, "R"},
{"Harga Jual\n(Rp)", 17, "R"},
{"HPP\n(Rp)", 17, "R"},
{"Total Jual\n(Rp)", 18, "R"},
{"Total HPP\n(Rp)", 18, "R"},
}
// ---------------------------------------------------------------------------
// Colours
// ---------------------------------------------------------------------------
const (
headerR, headerG, headerB = 30, 64, 120 // dark blue header bg
headerTextR, headerTextG, headerTextB = 255, 255, 255 // white header text
rowAltR, rowAltG, rowAltB = 245, 247, 250 // alternating row bg
borderR, borderG, borderB = 200, 200, 200 // light border
badgeTelurR, badgeTelurG, badgeTelurB = 59, 130, 246 // blue
badgeAyamR, badgeAyamG, badgeAyamB = 34, 197, 94 // green
badgeTradingR, badgeTradingG, badgeTradingB = 249, 115, 22 // orange
badgeDefaultR, badgeDefaultG, badgeDefaultB = 107, 114, 128 // gray
)
// ---------------------------------------------------------------------------
// Workbook builder
// ---------------------------------------------------------------------------
func buildMarketingReportPdf(items []dto.RepportMarketingItemDTO, meta marketingPdfQueryMeta) ([]byte, error) {
pdf := fpdf.New("L", "mm", "A4", "")
pdf.SetMargins(10, 12, 10)
pdf.SetAutoPageBreak(true, 12)
pdf.AddPage()
// ---- title ----
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(30, 64, 120)
pdf.CellFormat(0, 8, "Laporan > Penjualan Harian", "", 1, "L", false, 0, "")
// ---- subtitle ----
pdf.SetFont("Helvetica", "", 8)
pdf.SetTextColor(80, 80, 80)
dateLabel := buildMarketingPdfDateLabel(meta)
printedAt := formatMarketingPdfDateTime(meta.PrintedAt)
pdf.CellFormat(0, 5, fmt.Sprintf("%s Dicetak: %s", dateLabel, printedAt), "", 1, "L", false, 0, "")
pdf.Ln(3)
// ---- table ----
writeMarketingPdfHeader(pdf)
writeMarketingPdfRows(pdf, items)
writeMarketingPdfTotal(pdf, items)
return marshalMarketingPdf(pdf)
}
func marshalMarketingPdf(pdf *fpdf.Fpdf) ([]byte, error) {
w := &pdfByteBuffer{}
err := pdf.Output(w)
if err != nil {
return nil, err
}
return w.Bytes(), nil
}
// pdfByteBuffer implements io.Writer and accumulates bytes.
type pdfByteBuffer struct {
buf []byte
}
func (b *pdfByteBuffer) Write(p []byte) (n int, err error) {
b.buf = append(b.buf, p...)
return len(p), nil
}
func (b *pdfByteBuffer) Bytes() []byte { return b.buf }
// ---------------------------------------------------------------------------
// Header row
// ---------------------------------------------------------------------------
func writeMarketingPdfHeader(pdf *fpdf.Fpdf) {
pdf.SetFont("Helvetica", "B", 6.5)
pdf.SetFillColor(headerR, headerG, headerB)
pdf.SetTextColor(headerTextR, headerTextG, headerTextB)
pdf.SetDrawColor(borderR, borderG, borderB)
pdf.SetLineWidth(0.1)
headerH := 9.0 // height of header row (mm)
x0 := pdf.GetX()
y0 := pdf.GetY()
for _, col := range marketingPdfColumns {
lines := strings.Split(col.header, "\n")
lineH := headerH / float64(len(lines))
x := pdf.GetX()
for li, line := range lines {
pdf.SetXY(x, y0+float64(li)*lineH)
pdf.CellFormat(col.width, lineH, line, "", 0, "C", true, 0, "")
}
pdf.SetXY(x+col.width, y0)
}
_ = x0
pdf.SetXY(10, y0+headerH)
pdf.Ln(0)
}
// ---------------------------------------------------------------------------
// Data rows
// ---------------------------------------------------------------------------
func writeMarketingPdfRows(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO) {
pdf.SetFont("Helvetica", "", 6)
pdf.SetDrawColor(borderR, borderG, borderB)
pdf.SetLineWidth(0.1)
rowH := 6.0
for idx, item := range items {
// page break check
if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 {
pdf.AddPage()
writeMarketingPdfHeader(pdf)
pdf.SetFont("Helvetica", "", 6)
}
// alternating bg
if idx%2 == 1 {
pdf.SetFillColor(rowAltR, rowAltG, rowAltB)
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.SetTextColor(40, 40, 40)
y := pdf.GetY()
writeMarketingPdfRow(pdf, idx+1, item, rowH, y)
}
}
func writeMarketingPdfRow(pdf *fpdf.Fpdf, no int, item dto.RepportMarketingItemDTO, h, y float64) {
fill := true // use the fill colour already set
cols := marketingPdfColumns
x := 10.0 // left margin
values := marketingPdfRowValues(no, item)
for i, col := range cols {
pdf.SetXY(x, y)
if i == 10 { // Tipe Marketing → badge
drawMarketingTypeBadge(pdf, x, y, col.width, h, item.MarketingType)
} else {
pdf.CellFormat(col.width, h, values[i], "1", 0, col.align, fill, 0, "")
}
x += col.width
}
pdf.SetXY(10, y+h)
}
func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string {
warehouse := "-"
if item.Warehouse != nil {
warehouse = safeMarketingExportText(item.Warehouse.Name)
}
customer := "-"
if item.Customer != nil {
customer = safeMarketingExportText(item.Customer.Name)
}
sales := "-"
if item.Sales != nil {
sales = safeMarketingExportText(item.Sales.Name)
}
product := "-"
if item.Product != nil {
product = safeMarketingExportText(item.Product.Name)
}
return []string{
strconv.Itoa(no),
formatMarketingDate(item.SoDate),
formatMarketingDate(item.RealizationDate),
strconv.Itoa(item.AgingDays),
warehouse,
customer,
sales,
product,
safeMarketingExportText(item.DoNumber),
safeMarketingExportText(item.VehicleNumber),
safeMarketingExportText(item.MarketingType), // index 10, overridden by badge
formatMarketingPdfNumber(item.Qty),
formatMarketingPdfDecimal(item.AverageWeightKg),
formatMarketingPdfDecimal(item.TotalWeightKg),
formatMarketingPdfRupiah(item.SalesPricePerKg),
formatMarketingPdfRupiah(item.HppPricePerKg),
formatMarketingPdfRupiah(item.SalesAmount),
formatMarketingPdfRupiah(item.HppAmount),
}
}
// ---------------------------------------------------------------------------
// Total row
// ---------------------------------------------------------------------------
func writeMarketingPdfTotal(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO) {
summary := dto.ToSummaryFromDTOItems(items)
if summary == nil {
return
}
rowH := 6.5
if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 {
pdf.AddPage()
writeMarketingPdfHeader(pdf)
}
pdf.SetFont("Helvetica", "B", 6)
pdf.SetFillColor(220, 230, 245)
pdf.SetTextColor(30, 64, 120)
pdf.SetDrawColor(borderR, borderG, borderB)
pdf.SetLineWidth(0.1)
y := pdf.GetY()
x := 10.0
// merge first 11 cols (No … Tipe Marketing) into "TOTAL" label
mergedWidth := 0.0
for i := 0; i < 11; i++ {
mergedWidth += marketingPdfColumns[i].width
}
pdf.SetXY(x, y)
pdf.CellFormat(mergedWidth, rowH, "TOTAL", "1", 0, "R", true, 0, "")
x += mergedWidth
totals := []string{
formatMarketingPdfNumber(float64(summary.TotalQty)),
formatMarketingPdfDecimal(summary.AverageWeightKg),
formatMarketingPdfDecimal(summary.TotalWeightKg),
formatMarketingPdfRupiah(summary.AverageSalesPrice),
formatMarketingPdfRupiah(summary.TotalHppPricePerKg),
formatMarketingPdfRupiah(float64(summary.TotalSalesAmount)),
formatMarketingPdfRupiah(float64(summary.TotalHppAmount)),
}
for i, val := range totals {
col := marketingPdfColumns[11+i]
pdf.SetXY(x, y)
pdf.CellFormat(col.width, rowH, val, "1", 0, "R", true, 0, "")
x += col.width
}
pdf.SetXY(10, y+rowH)
}
// ---------------------------------------------------------------------------
// Badge drawer
// ---------------------------------------------------------------------------
func drawMarketingTypeBadge(pdf *fpdf.Fpdf, x, y, w, h float64, marketingType string) {
lower := strings.ToLower(strings.TrimSpace(marketingType))
var r, g, b int
switch lower {
case "telur":
r, g, b = badgeTelurR, badgeTelurG, badgeTelurB
case "ayam":
r, g, b = badgeAyamR, badgeAyamG, badgeAyamB
case "trading":
r, g, b = badgeTradingR, badgeTradingG, badgeTradingB
default:
r, g, b = badgeDefaultR, badgeDefaultG, badgeDefaultB
}
// badge background (slightly inset from the cell border)
padH := 1.2
padV := 1.2
bx := x + padH
by := y + padV
bw := w - padH*2
bh := h - padV*2
pdf.SetFillColor(r, g, b)
pdf.SetDrawColor(r, g, b)
pdf.RoundedRect(bx, by, bw, bh, 1.5, "1234", "FD")
// border of the cell itself (transparent bg, just border)
pdf.SetDrawColor(borderR, borderG, borderB)
pdf.SetFillColor(255, 255, 255)
pdf.SetXY(x, y)
pdf.CellFormat(w, h, "", "1", 0, "C", false, 0, "")
// badge label
pdf.SetFont("Helvetica", "B", 5.5)
pdf.SetTextColor(255, 255, 255)
label := strings.Title(strings.ToLower(marketingType))
if label == "" {
label = "-"
}
textW := pdf.GetStringWidth(label)
pdf.SetXY(bx+(bw-textW)/2, by+(bh-3)/2)
pdf.CellFormat(textW, 3, label, "", 0, "C", false, 0, "")
// reset
pdf.SetFont("Helvetica", "", 6)
pdf.SetTextColor(40, 40, 40)
pdf.SetDrawColor(borderR, borderG, borderB)
}
// ---------------------------------------------------------------------------
// Helpers — date/time
// ---------------------------------------------------------------------------
// buildMarketingPdfMeta converts raw query string dates into display meta.
func buildMarketingPdfMeta(startDate, endDate string) marketingPdfQueryMeta {
loc, _ := time.LoadLocation("Asia/Jakarta")
format := func(s string) string {
t, err := time.ParseInLocation("2006-01-02", s, loc)
if err != nil {
return s
}
return t.Format("02 January 2006")
}
return marketingPdfQueryMeta{
StartDate: format(startDate),
EndDate: format(endDate),
PrintedAt: time.Now(),
}
}
func buildMarketingPdfDateLabel(meta marketingPdfQueryMeta) string {
if meta.StartDate != "" && meta.EndDate != "" {
return fmt.Sprintf("Tanggal: %s - %s", meta.StartDate, meta.EndDate)
}
if meta.StartDate != "" {
return fmt.Sprintf("Tanggal: %s", meta.StartDate)
}
if meta.EndDate != "" {
return fmt.Sprintf("Tanggal: %s", meta.EndDate)
}
return fmt.Sprintf("Tanggal: %s", time.Now().Format("02 January 2006"))
}
func formatMarketingPdfDateTime(t time.Time) string {
if t.IsZero() {
t = time.Now()
}
loc, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(loc)
}
return t.Format("02 Jan 2006 15:04")
}
// ---------------------------------------------------------------------------
// Helpers — number formatting
// ---------------------------------------------------------------------------
// formatMarketingPdfNumber formats a float64 as an integer with period-thousands separator.
// e.g. 7299 → "7.299"
func formatMarketingPdfNumber(v float64) string {
return formatMarketingPdfThousands(int64(math.Round(v)))
}
// formatMarketingPdfDecimal formats a float64 with 2 decimal places (Indonesian locale).
// e.g. 452.54 → "452,54"
func formatMarketingPdfDecimal(v float64) string {
rounded := math.Round(v*100) / 100
intPart := int64(rounded)
fracPart := int64(math.Round((rounded - float64(intPart)) * 100))
if fracPart < 0 {
fracPart = -fracPart
}
return fmt.Sprintf("%s,%02d", formatMarketingPdfThousands(intPart), fracPart)
}
// formatMarketingPdfRupiah formats a float64 as Rupiah with Indonesian locale.
// Drops trailing ",00" decimals for whole numbers.
// e.g. 25500 → "Rp 25.500" | 240896.94 → "Rp 240.896,94"
func formatMarketingPdfRupiah(v float64) string {
rounded := math.Round(v*100) / 100
intPart := int64(rounded)
fracPart := int64(math.Round((rounded - float64(intPart)) * 100))
if fracPart < 0 {
fracPart = -fracPart
}
negative := intPart < 0
absInt := intPart
if negative {
absInt = -intPart
}
s := formatMarketingPdfThousands(absInt)
var result string
if fracPart == 0 {
result = "Rp " + s
} else {
result = fmt.Sprintf("Rp %s,%02d", s, fracPart)
}
if negative {
return "Rp -" + s
}
return result
}
// marketingPdfPageHeight returns the page height in mm.
func marketingPdfPageHeight(pdf *fpdf.Fpdf) float64 {
_, h := pdf.GetPageSize()
return h
}
// formatMarketingPdfThousands inserts period every 3 digits.
func formatMarketingPdfThousands(v int64) string {
negative := v < 0
abs := v
if negative {
abs = -v
}
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 "-" + b.String()
}
return b.String()
}
@@ -2,7 +2,7 @@ package validation
type ExpenseQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"`
Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"`
SupplierId int64 `query:"supplier_id" validate:"omitempty"`
@@ -65,7 +65,7 @@ type DebtSupplierQuery struct {
type HppPerKandangQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=10,gt=0"`
Period string `query:"period" validate:"required"`
ShowUnrecorded bool `query:"show_unrecorded"`
AreaIDs []int64 `query:"-"`
@@ -82,7 +82,7 @@ type HppV2BreakdownQuery struct {
type ExpenseDepreciationQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=10,gt=0"`
Period string `query:"period" validate:"required,datetime=2006-01-02"`
ForceRecompute bool `query:"force_recompute"`
ProjectFlockIDs []int64 `query:"-"`
@@ -105,7 +105,7 @@ type ProductionResultQuery struct {
type CustomerPaymentQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"`
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"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
@@ -0,0 +1,47 @@
package controller
import (
"github.com/gofiber/fiber/v2"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/services"
"gitlab.com/mbugroup/lti-api.git/internal/response"
)
type SystemSettingController struct {
Service service.SystemSettingService
}
func NewSystemSettingController(svc service.SystemSettingService) *SystemSettingController {
return &SystemSettingController{Service: svc}
}
func (ctrl *SystemSettingController) GetAll(c *fiber.Ctx) error {
settings, err := ctrl.Service.GetAll(c.Context())
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all system settings successfully",
Data: settings,
})
}
type setAllowNegativePakanOVKRequest struct {
Value bool `json:"value"`
}
func (ctrl *SystemSettingController) SetAllowNegativePakanOVK(c *fiber.Ctx) error {
var req setAllowNegativePakanOVKRequest
if err := c.BodyParser(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Request body tidak valid")
}
if err := ctrl.Service.SetAllowNegativePakanOVK(c.Context(), req.Value); err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Setting berhasil diperbarui",
})
}
@@ -0,0 +1,23 @@
package systemsettings
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/repositories"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type SystemSettingsModule struct{}
func (SystemSettingsModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
userRepo := rUser.NewUserRepository(db)
userSvc := sUser.NewUserService(userRepo, validate)
repo := repository.NewSystemSettingRepository(db)
svc := service.NewSystemSettingService(repo)
SystemSettingRoutes(router, userSvc, svc)
}
@@ -0,0 +1,62 @@
package repository
import (
"context"
"errors"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type SystemSettingRepository interface {
Get(ctx context.Context, key string) (*entity.SystemSetting, error)
Set(ctx context.Context, key, value string) error
List(ctx context.Context) ([]entity.SystemSetting, error)
GetAllowNegativePakanOVK(ctx context.Context) (bool, error)
}
type systemSettingRepositoryImpl struct {
db *gorm.DB
}
func NewSystemSettingRepository(db *gorm.DB) SystemSettingRepository {
return &systemSettingRepositoryImpl{db: db}
}
func (r *systemSettingRepositoryImpl) Get(ctx context.Context, key string) (*entity.SystemSetting, error) {
var setting entity.SystemSetting
if err := r.db.WithContext(ctx).Where("key = ?", key).First(&setting).Error; err != nil {
return nil, err
}
return &setting, nil
}
func (r *systemSettingRepositoryImpl) Set(ctx context.Context, key, value string) error {
return r.db.WithContext(ctx).
Model(&entity.SystemSetting{}).
Where("key = ?", key).
Updates(map[string]interface{}{
"value": value,
"updated_at": time.Now(),
}).Error
}
func (r *systemSettingRepositoryImpl) List(ctx context.Context) ([]entity.SystemSetting, error) {
var settings []entity.SystemSetting
if err := r.db.WithContext(ctx).Order("key ASC").Find(&settings).Error; err != nil {
return nil, err
}
return settings, nil
}
func (r *systemSettingRepositoryImpl) GetAllowNegativePakanOVK(ctx context.Context) (bool, error) {
setting, err := r.Get(ctx, entity.SystemSettingKeyAllowNegativePakanOVK)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return setting.Value == "true", nil
}
+19
View File
@@ -0,0 +1,19 @@
package systemsettings
import (
"github.com/gofiber/fiber/v2"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/controllers"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/services"
userService "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
func SystemSettingRoutes(v1 fiber.Router, u userService.UserService, svc service.SystemSettingService) {
ctrl := controller.NewSystemSettingController(svc)
route := v1.Group("/system-settings")
route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Patch("/allow-negative-pakan-ovk", m.RequirePermissions(m.P_SystemSettingUpdate), ctrl.SetAllowNegativePakanOVK)
}
@@ -0,0 +1,46 @@
package service
import (
"context"
"github.com/gofiber/fiber/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings/repositories"
)
type SystemSettingService interface {
GetAll(ctx context.Context) ([]entity.SystemSetting, error)
GetAllowNegativePakanOVK(ctx context.Context) (bool, error)
SetAllowNegativePakanOVK(ctx context.Context, allow bool) error
}
type systemSettingService struct {
Repository repository.SystemSettingRepository
}
func NewSystemSettingService(repo repository.SystemSettingRepository) SystemSettingService {
return &systemSettingService{Repository: repo}
}
func (s *systemSettingService) GetAll(ctx context.Context) ([]entity.SystemSetting, error) {
settings, err := s.Repository.List(ctx)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil system settings")
}
return settings, nil
}
func (s *systemSettingService) GetAllowNegativePakanOVK(ctx context.Context) (bool, error) {
return s.Repository.GetAllowNegativePakanOVK(ctx)
}
func (s *systemSettingService) SetAllowNegativePakanOVK(ctx context.Context, allow bool) error {
value := "false"
if allow {
value = "true"
}
if err := s.Repository.Set(ctx, entity.SystemSettingKeyAllowNegativePakanOVK, value); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengubah setting allow_negative_pakan_ovk")
}
return nil
}
+2
View File
@@ -23,6 +23,7 @@ import (
ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso"
users "gitlab.com/mbugroup/lti-api.git/internal/modules/users"
dashboards "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards"
systemsettings "gitlab.com/mbugroup/lti-api.git/internal/modules/system-settings"
// MODULE IMPORTS
)
@@ -50,6 +51,7 @@ func Routes(app *fiber.App, db *gorm.DB) {
finance.FinanceModule{},
dailyChecklists.DailyChecklistModule{},
dashboards.DashboardModule{},
systemsettings.SystemSettingsModule{},
// MODULE REGISTRY
}