diff --git a/cmd/consolidate-duplicate-product-warehouses/main.go b/cmd/consolidate-duplicate-product-warehouses/main.go index ba3f5462..e4c4cd81 100644 --- a/cmd/consolidate-duplicate-product-warehouses/main.go +++ b/cmd/consolidate-duplicate-product-warehouses/main.go @@ -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"). diff --git a/go.mod b/go.mod index d0ffe677..d0c07314 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index ab7d76b4..09c2a0df 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/database/migrations/20260428000001_create_system_settings.down.sql b/internal/database/migrations/20260428000001_create_system_settings.down.sql new file mode 100644 index 00000000..1e2a497b --- /dev/null +++ b/internal/database/migrations/20260428000001_create_system_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS system_settings; diff --git a/internal/database/migrations/20260428000001_create_system_settings.up.sql b/internal/database/migrations/20260428000001_create_system_settings.up.sql new file mode 100644 index 00000000..ecc7ca2f --- /dev/null +++ b/internal/database/migrations/20260428000001_create_system_settings.up.sql @@ -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'); diff --git a/internal/entities/system_setting.go b/internal/entities/system_setting.go new file mode 100644 index 00000000..0680fae5 --- /dev/null +++ b/internal/entities/system_setting.go @@ -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" +} diff --git a/internal/entities/warehouse.go b/internal/entities/warehouse.go index fe2d96aa..75c9cc01 100644 --- a/internal/entities/warehouse.go +++ b/internal/entities/warehouse.go @@ -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 diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index f9d23d3e..d2381b78 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -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" diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 9d3ad573..f407c603 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -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"` } diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index 94cbd371..a3d68713 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -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), } } diff --git a/internal/modules/inventory/adjustments/validations/adjustment.validation.go b/internal/modules/inventory/adjustments/validations/adjustment.validation.go index 8f0abbf7..7a5bd401 100644 --- a/internal/modules/inventory/adjustments/validations/adjustment.validation.go +++ b/internal/modules/inventory/adjustments/validations/adjustment.validation.go @@ -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"` diff --git a/internal/modules/inventory/product-stocks/validations/product-stock.validation.go b/internal/modules/inventory/product-stocks/validations/product-stock.validation.go index a3c6b9a7..e04f5320 100644 --- a/internal/modules/inventory/product-stocks/validations/product-stock.validation.go +++ b/internal/modules/inventory/product-stocks/validations/product-stock.validation.go @@ -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"` } diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 97cff885..344e8d96 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -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 diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index f4c7d045..e05f3be1 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -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() } diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go index 3f810e53..a5399858 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go @@ -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) } } diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 164c96bc..922da584 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -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"` diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go index 218f6d81..7e30445f 100644 --- a/internal/modules/inventory/transfers/validations/transfer.validation.go +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -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"` diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index 5a53c174..4af4f89a 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -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"` diff --git a/internal/modules/master/areas/validations/area.validation.go b/internal/modules/master/areas/validations/area.validation.go index a7004c26..aebb827c 100644 --- a/internal/modules/master/areas/validations/area.validation.go +++ b/internal/modules/master/areas/validations/area.validation.go @@ -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"` } diff --git a/internal/modules/master/banks/validations/bank.validation.go b/internal/modules/master/banks/validations/bank.validation.go index 34f1db27..592c34d9 100644 --- a/internal/modules/master/banks/validations/bank.validation.go +++ b/internal/modules/master/banks/validations/bank.validation.go @@ -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"` } diff --git a/internal/modules/master/config-checklists/validations/config-checklist.validation.go b/internal/modules/master/config-checklists/validations/config-checklist.validation.go index 10f477b7..a234c7bc 100644 --- a/internal/modules/master/config-checklists/validations/config-checklist.validation.go +++ b/internal/modules/master/config-checklists/validations/config-checklist.validation.go @@ -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"` } diff --git a/internal/modules/master/customers/validations/customer.validation.go b/internal/modules/master/customers/validations/customer.validation.go index e04c33aa..5b814b37 100644 --- a/internal/modules/master/customers/validations/customer.validation.go +++ b/internal/modules/master/customers/validations/customer.validation.go @@ -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"` } diff --git a/internal/modules/master/fcrs/validations/fcr.validation.go b/internal/modules/master/fcrs/validations/fcr.validation.go index a5e070cc..c8d7d9fa 100644 --- a/internal/modules/master/fcrs/validations/fcr.validation.go +++ b/internal/modules/master/fcrs/validations/fcr.validation.go @@ -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"` } diff --git a/internal/modules/master/flocks/validations/floc.validation.go b/internal/modules/master/flocks/validations/floc.validation.go index 56bbd601..750575d8 100644 --- a/internal/modules/master/flocks/validations/floc.validation.go +++ b/internal/modules/master/flocks/validations/floc.validation.go @@ -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"` } diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index 6378ac18..f265a08e 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -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"` } diff --git a/internal/modules/master/phase-activities/validations/phase-activity.validation.go b/internal/modules/master/phase-activities/validations/phase-activity.validation.go index 54186315..d153f530 100644 --- a/internal/modules/master/phase-activities/validations/phase-activity.validation.go +++ b/internal/modules/master/phase-activities/validations/phase-activity.validation.go @@ -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"` } diff --git a/internal/modules/master/phasess/validations/phases.validation.go b/internal/modules/master/phasess/validations/phases.validation.go index c22d4208..09152c78 100644 --- a/internal/modules/master/phasess/validations/phases.validation.go +++ b/internal/modules/master/phasess/validations/phases.validation.go @@ -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"` } diff --git a/internal/modules/master/product-categories/validations/product-category.validation.go b/internal/modules/master/product-categories/validations/product-category.validation.go index 46cfaedb..f12f6c97 100644 --- a/internal/modules/master/product-categories/validations/product-category.validation.go +++ b/internal/modules/master/product-categories/validations/product-category.validation.go @@ -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"` } diff --git a/internal/modules/master/production-standards/validations/production-standard.validation.go b/internal/modules/master/production-standards/validations/production-standard.validation.go index cdc321f8..d0acfc9d 100644 --- a/internal/modules/master/production-standards/validations/production-standard.validation.go +++ b/internal/modules/master/production-standards/validations/production-standard.validation.go @@ -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"` } diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index 0c49a4ef..0a70ebb4 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -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") }) diff --git a/internal/modules/master/products/validations/product.validation.go b/internal/modules/master/products/validations/product.validation.go index c9068e6e..b6724716 100644 --- a/internal/modules/master/products/validations/product.validation.go +++ b/internal/modules/master/products/validations/product.validation.go @@ -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"` } diff --git a/internal/modules/master/uoms/validations/uom.validation.go b/internal/modules/master/uoms/validations/uom.validation.go index a7004c26..aebb827c 100644 --- a/internal/modules/master/uoms/validations/uom.validation.go +++ b/internal/modules/master/uoms/validations/uom.validation.go @@ -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"` } diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go index 2a2a9f87..33c45d5f 100644 --- a/internal/modules/master/warehouses/validations/warehouse.validation.go +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -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"` diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index da175b61..509da63d 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -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) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index beaa0899..ce92d428 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -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 } diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index c6370133..1f65a4ed 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -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"` } diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index adbf6a40..f1b61ad7 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -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) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 55315a16..4efa6b06 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -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") diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 73d4fdef..44a34cfa 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -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"` } diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 54dea5bd..cc467b8f 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -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"` diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index a5de422f..b13260ea 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -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). diff --git a/internal/modules/repports/controllers/repport.marketing.export.go b/internal/modules/repports/controllers/repport.marketing.export.go new file mode 100644 index 00000000..866590c4 --- /dev/null +++ b/internal/modules/repports/controllers/repport.marketing.export.go @@ -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() +} diff --git a/internal/modules/repports/controllers/repport.marketing.pdf.go b/internal/modules/repports/controllers/repport.marketing.pdf.go new file mode 100644 index 00000000..8bac933f --- /dev/null +++ b/internal/modules/repports/controllers/repport.marketing.pdf.go @@ -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() +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index fac647f4..8b72152f 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -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"` diff --git a/internal/modules/system-settings/controllers/system_setting.controller.go b/internal/modules/system-settings/controllers/system_setting.controller.go new file mode 100644 index 00000000..00573c2a --- /dev/null +++ b/internal/modules/system-settings/controllers/system_setting.controller.go @@ -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", + }) +} diff --git a/internal/modules/system-settings/module.go b/internal/modules/system-settings/module.go new file mode 100644 index 00000000..d9f2bb7e --- /dev/null +++ b/internal/modules/system-settings/module.go @@ -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) +} diff --git a/internal/modules/system-settings/repositories/system_setting.repository.go b/internal/modules/system-settings/repositories/system_setting.repository.go new file mode 100644 index 00000000..9f7d125d --- /dev/null +++ b/internal/modules/system-settings/repositories/system_setting.repository.go @@ -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 +} diff --git a/internal/modules/system-settings/route.go b/internal/modules/system-settings/route.go new file mode 100644 index 00000000..bc561a55 --- /dev/null +++ b/internal/modules/system-settings/route.go @@ -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) +} diff --git a/internal/modules/system-settings/services/system_setting.service.go b/internal/modules/system-settings/services/system_setting.service.go new file mode 100644 index 00000000..bfb90f4a --- /dev/null +++ b/internal/modules/system-settings/services/system_setting.service.go @@ -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 +} diff --git a/internal/route/route.go b/internal/route/route.go index 71682d2b..5a9317ad 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -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 }