From fcde3b0a366f67d516ecf9ae88524b42a50d6656 Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 17 Apr 2026 21:26:56 +0700 Subject: [PATCH 1/8] init depresiasi --- docs/openapi/read-api.json | 212 ++++ docs/openapi/read-api.yaml | 135 ++ docs/postman/read-api.collection.json | 26 + internal/apikeys/defaults.go | 1 + .../repository/common.hpp.repository.go | 148 ++- internal/common/service/common.hpp.service.go | 200 ++- ...preciation_snapshot_invalidator.service.go | 103 ++ ...s_and_add_house_type_to_kandangs..down.sql | 6 + ...rds_and_add_house_type_to_kandangs..up.sql | 18 + ...reate_farm_depreciation_snapshots.down.sql | 4 + ..._create_farm_depreciation_snapshots.up.sql | 22 + ...e_farm_depreciation_manual_inputs.down.sql | 2 + ...ate_farm_depreciation_manual_inputs.up.sql | 16 + .../farm_depreciation_manual_input.go | 17 + .../entities/farm_depreciation_snapshot.go | 21 + .../entities/house_depreciation_standard.go | 16 + internal/entities/kandang.go | 1 + internal/middleware/permissions.go | 15 +- .../expenses/services/expense.service.go | 120 ++ .../chickins/services/chickin.service.go | 75 ++ .../project-flock-kandangs/route.go | 8 +- .../recordings/services/recording.service.go | 75 +- .../services/transfer_laying.service.go | 78 ++ .../purchases/services/purchase.service.go | 137 ++- .../controllers/repport.controller.go | 69 ++ .../dto/repportExpenseDepreciation.dto.go | 43 + internal/modules/repports/module.go | 23 +- .../expense_depreciation.repository.go | 326 +++++ internal/modules/repports/route.go | 3 + .../repports/services/repport.service.go | 576 +++++++++ .../validations/repport.validation.go | 15 + internal/readapi/readapi.go | 17 + internal/utils/constant.go | 11 + .../sql/seed_house_depreciation_standards.sql | 1095 +++++++++++++++++ 34 files changed, 3588 insertions(+), 46 deletions(-) create mode 100644 internal/common/service/depreciation_snapshot_invalidator.service.go create mode 100644 internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..down.sql create mode 100644 internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..up.sql create mode 100644 internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.down.sql create mode 100644 internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.up.sql create mode 100644 internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.down.sql create mode 100644 internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.up.sql create mode 100644 internal/entities/farm_depreciation_manual_input.go create mode 100644 internal/entities/farm_depreciation_snapshot.go create mode 100644 internal/entities/house_depreciation_standard.go create mode 100644 internal/modules/repports/dto/repportExpenseDepreciation.dto.go create mode 100644 internal/modules/repports/repositories/expense_depreciation.repository.go create mode 100644 scripts/sql/seed_house_depreciation_standards.sql diff --git a/docs/openapi/read-api.json b/docs/openapi/read-api.json index f2f91f7d..dab696fa 100644 --- a/docs/openapi/read-api.json +++ b/docs/openapi/read-api.json @@ -8559,6 +8559,218 @@ ] } }, + "/api/reports/expense/depreciation": { + "get": { + "description": "Read access to `/api/reports/expense/depreciation`.", + "parameters": [ + { + "description": "Page number.", + "example": 1, + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Page size.", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Daily period filter (YYYY-MM-DD).", + "example": "2026-01-01", + "in": "query", + "name": "period", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated project flock ids.", + "example": "1,2", + "in": "query", + "name": "project_flock_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated area ids.", + "example": "1,2", + "in": "query", + "name": "area_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated location ids.", + "example": "1,2", + "in": "query", + "name": "location_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedEnvelope" + } + } + }, + "description": "Successful response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "GET api / reports / expense / depreciation", + "tags": [ + "Reports" + ] + } + }, + "/api/reports/expense/depreciation/manual-inputs": { + "get": { + "description": "Read access to `/api/reports/expense/depreciation/manual-inputs`.", + "parameters": [ + { + "description": "Page number.", + "example": 1, + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Page size.", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Comma separated project flock ids.", + "example": "1,2", + "in": "query", + "name": "project_flock_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated area ids.", + "example": "1,2", + "in": "query", + "name": "area_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma separated location ids.", + "example": "1,2", + "in": "query", + "name": "location_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedEnvelope" + } + } + }, + "description": "Successful response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "GET api / reports / expense / depreciation / manual inputs", + "tags": [ + "Reports" + ] + } + }, "/api/reports/hpp-per-kandang": { "get": { "description": "Read access to `/api/reports/hpp-per-kandang`.", diff --git a/docs/openapi/read-api.yaml b/docs/openapi/read-api.yaml index 7e62562f..f12674a4 100644 --- a/docs/openapi/read-api.yaml +++ b/docs/openapi/read-api.yaml @@ -5318,6 +5318,141 @@ paths: summary: GET api / reports / expense tags: - Reports + /api/reports/expense/depreciation: + get: + description: Read access to `/api/reports/expense/depreciation`. + parameters: + - description: Page number. + example: 1 + in: query + name: page + required: false + schema: + type: integer + - description: Page size. + example: 10 + in: query + name: limit + required: false + schema: + type: integer + - description: Daily period filter (YYYY-MM-DD). + example: "2026-01-01" + in: query + name: period + required: true + schema: + type: string + - description: Comma separated project flock ids. + example: 1,2 + in: query + name: project_flock_id + required: false + schema: + type: string + - description: Comma separated area ids. + example: 1,2 + in: query + name: area_id + required: false + schema: + type: string + - description: Comma separated location ids. + example: 1,2 + in: query + name: location_id + required: false + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEnvelope' + description: Successful response + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Forbidden + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: GET api / reports / expense / depreciation + tags: + - Reports + /api/reports/expense/depreciation/manual-inputs: + get: + description: Read access to `/api/reports/expense/depreciation/manual-inputs`. + parameters: + - description: Page number. + example: 1 + in: query + name: page + required: false + schema: + type: integer + - description: Page size. + example: 10 + in: query + name: limit + required: false + schema: + type: integer + - description: Comma separated project flock ids. + example: 1,2 + in: query + name: project_flock_id + required: false + schema: + type: string + - description: Comma separated area ids. + example: 1,2 + in: query + name: area_id + required: false + schema: + type: string + - description: Comma separated location ids. + example: 1,2 + in: query + name: location_id + required: false + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEnvelope' + description: Successful response + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Forbidden + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: GET api / reports / expense / depreciation / manual inputs + tags: + - Reports /api/reports/hpp-per-kandang: get: description: Read access to `/api/reports/hpp-per-kandang`. diff --git a/docs/postman/read-api.collection.json b/docs/postman/read-api.collection.json index 0ccdba5c..aa262c80 100644 --- a/docs/postman/read-api.collection.json +++ b/docs/postman/read-api.collection.json @@ -1439,6 +1439,32 @@ "url": "{{base_url}}/api/reports/expense?page=1\u0026limit=10\u0026search=operasional\u0026category=BOP\u0026supplier_id=1\u0026kandang_id=1\u0026project_flock_kandang_id=1\u0026nonstock_id=1\u0026location_id=1\u0026area_id=1\u0026realization_date=2026-01-15" } }, + { + "name": "GET api / reports / expense / depreciation", + "request": { + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": "{{base_url}}/api/reports/expense/depreciation?page=1\u0026limit=10\u0026period=2026-01-01\u0026project_flock_id=1,2\u0026area_id=1,2\u0026location_id=1,2" + } + }, + { + "name": "GET api / reports / expense / depreciation / manual inputs", + "request": { + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": "{{base_url}}/api/reports/expense/depreciation/manual-inputs?page=1\u0026limit=10\u0026project_flock_id=1,2\u0026area_id=1,2\u0026location_id=1,2" + } + }, { "name": "GET api / reports / hpp per kandang", "request": { diff --git a/internal/apikeys/defaults.go b/internal/apikeys/defaults.go index 29daeda5..33662187 100644 --- a/internal/apikeys/defaults.go +++ b/internal/apikeys/defaults.go @@ -82,6 +82,7 @@ func DefaultDashboardPermissions() []string { "lti.repport.debtsupplier.list", "lti.repport.delivery.list", "lti.repport.expense.list", + "lti.repport.expense.depreciation.manage", "lti.repport.gethppperkandang.list", "lti.repport.production_result.list", "lti.repport.purchasesupplier.list", diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index bc5037ec..d41387af 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -23,6 +24,7 @@ type HppCostRepository interface { GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) + GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error) } type HppRepositoryImpl struct { @@ -48,12 +50,32 @@ func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, proje } func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableProjectChickin := fifo.UsableKeyProjectChickin.String() + var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). - Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). - Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Select(` + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableAdjustment, + ). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableProjectChickin, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeTraceChickin, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Scan(&total).Error if err != nil { @@ -85,7 +107,7 @@ func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockK Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock). Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs). - Where("f.name = ?", utils.FlagEkspedisi). + // Where("f.name = ?", utils.FlagEkspedisi). Scan(&total).Error if err != nil { return 0, err @@ -100,15 +122,35 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa date = &now } + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableRecordingStock := fifo.UsableKeyRecordingStock.String() + var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). + Select(` + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableAdjustment, + ). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). - Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableRecordingStock, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("f.name = ?", utils.FlagPakan). @@ -132,15 +174,34 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan utils.FlagVitamin, utils.FlagKimia, } + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableRecordingStock := fifo.UsableKeyRecordingStock.String() var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). + Select(` + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableAdjustment, + ). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). - Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableRecordingStock, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags). @@ -169,22 +230,28 @@ func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlock func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { stockablePurchase := fifo.StockableKeyPurchaseItems.String() stockableTransferIn := fifo.StockableKeyStockTransferIn.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() usableProjectChickin := fifo.UsableKeyProjectChickin.String() var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select(` - COALESCE(SUM(sa.qty * CASE - WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) - ELSE 0 - END), 0)`, - stockablePurchase, stockableTransferIn). + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableTransferIn, + stockableAdjustment, + ). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id"). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). Scan(&total).Error if err != nil { @@ -215,6 +282,33 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang return 0, 0, err } + var adjustmentTotalWeight float64 + adjustmentSubQuery := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT ast.id AS adjustment_id, ast.price AS price"). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = std.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyStockTransferOut.String(), + fifo.StockableKeyAdjustmentIn.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND ast.product_warehouse_id = std.source_product_warehouse_id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date) + + err = r.db.WithContext(ctx). + Table("(?) AS adjustment_sources", adjustmentSubQuery). + Select("COALESCE(SUM(adjustment_sources.price), 0)"). + Scan(&adjustmentTotalWeight).Error + if err != nil { + return 0, 0, err + } + + totals.TotalWeightKg += adjustmentTotalWeight + return totals.TotalPieces, totals.TotalWeightKg, nil } @@ -311,3 +405,25 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec return summary.ProjectFlockID, summary.TotalQty, nil } + +func (r *HppRepositoryImpl) GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error) { + type row struct { + TotalCost float64 + } + + var selected row + err := r.db.WithContext(ctx). + Table("farm_depreciation_manual_inputs"). + Select("total_cost"). + Where("project_flock_id = ?", projectFlockId). + Limit(1). + Take(&selected).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + + return selected.TotalCost, nil +} diff --git a/internal/common/service/common.hpp.service.go b/internal/common/service/common.hpp.service.go index b1f1a1b1..6ea9ffa3 100644 --- a/internal/common/service/common.hpp.service.go +++ b/internal/common/service/common.hpp.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "log" "math" "time" @@ -39,77 +40,108 @@ func NewHppService(hppRepo commonRepo.HppCostRepository) HppService { } func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) { + logHpp("CalculateHppCost", "start project_flock_kandang_id=%d input_date=%s", projectFlockKandangId, formatTimePtr(date)) if date == nil { now := time.Now() date = &now } + logHpp("CalculateHppCost", "normalized_date=%s", formatTimePtr(date)) location, err := time.LoadLocation("Asia/Jakarta") if err != nil { + logHpp("CalculateHppCost", "load_location_error=%v", err) return nil, err } + logHpp("CalculateHppCost", "location=%s", location.String()) startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) endOfDay := startOfDay.Add(24 * time.Hour) + logHpp("CalculateHppCost", "start_of_day=%s end_of_day=%s", startOfDay.Format(time.RFC3339), endOfDay.Format(time.RFC3339)) depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay) if err != nil { + logHpp("CalculateHppCost", "get_depresiasi_transfer_error=%v", err) return nil, err } + logHpp("CalculateHppCost", "depresiasi_transfer=%f", depresiasiTransfer) totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer) if err != nil { + logHpp("CalculateHppCost", "get_total_production_cost_error=%v", err) return nil, err } - - return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) - + logHpp("CalculateHppCost", "total_production_cost=%f", totalProductionCost) + result, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) + if err != nil { + logHpp("CalculateHppCost", "get_hpp_estimation_dan_realisasi_error=%v", err) + return nil, err + } + logHpp("CalculateHppCost", "done estimation=%+v real=%+v", result.Estimation, result.Real) + return result, nil } func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) { + logHpp("GetTotalDepresiasiFlockGrowing", "start source_project_flock_id=%d input_date=%s", sourceProjectFlockID, formatTimePtr(date)) if date == nil { now := time.Now() date = &now } + logHpp("GetTotalDepresiasiFlockGrowing", "normalized_date=%s", formatTimePtr(date)) if s.hppRepo == nil { + logHpp("GetTotalDepresiasiFlockGrowing", "repo_nil return=0") return 0, nil } kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) if err != nil { + logHpp("GetTotalDepresiasiFlockGrowing", "get_project_flock_kandang_ids_error=%v", err) return 0, err } + logHpp("GetTotalDepresiasiFlockGrowing", "kandang_ids=%v", kandangIDs) docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs) if err != nil { + logHpp("GetTotalDepresiasiFlockGrowing", "get_doc_cost_error=%v", err) return 0, err } + logHpp("GetTotalDepresiasiFlockGrowing", "doc_cost=%f", docCost) budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID) if err != nil { + logHpp("GetTotalDepresiasiFlockGrowing", "get_budget_cost_error=%v", err) return 0, err } + logHpp("GetTotalDepresiasiFlockGrowing", "budget_cost=%f", budgetCost) expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs) if err != nil { + logHpp("GetTotalDepresiasiFlockGrowing", "get_expedision_cost_error=%v", err) return 0, err } + logHpp("GetTotalDepresiasiFlockGrowing", "expedision_cost=%f", expedisionCost) feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date) if err != nil { + logHpp("GetTotalDepresiasiFlockGrowing", "get_feed_usage_cost_error=%v", err) return 0, err } + logHpp("GetTotalDepresiasiFlockGrowing", "feed_cost=%f", feedCost) ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date) if err != nil { + logHpp("GetTotalDepresiasiFlockGrowing", "get_ovk_usage_cost_error=%v", err) return 0, err } + logHpp("GetTotalDepresiasiFlockGrowing", "ovk_cost=%f", ovkCost) - return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil + total := docCost + budgetCost + expedisionCost + feedCost + ovkCost + logHpp("GetTotalDepresiasiFlockGrowing", "done total=%f", total) + return total, nil } func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) { + logHpp("GetTotalProductionCost", "start project_flock_kandang_id=%d end_date=%s depresiasi_transfer=%f", projectFlockKandangId, formatTimePtr(endDate), depresiasiTransfer) // if date == nil { // now := time.Now() // date = &now @@ -117,125 +149,248 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId) if err != nil { + logHpp("GetTotalProductionCost", "get_pullet_cost_error=%v", err) return 0, err } + logHpp("GetTotalProductionCost", "cost_pullet=%f", costPullet) costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { + logHpp("GetTotalProductionCost", "get_feed_usage_cost_error=%v", err) return 0, err } + logHpp("GetTotalProductionCost", "cost_feed=%f", costFeed) costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { + logHpp("GetTotalProductionCost", "get_ovk_usage_cost_error=%v", err) return 0, err } + logHpp("GetTotalProductionCost", "cost_ovk=%f", costOvk) costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId}) if err != nil { + logHpp("GetTotalProductionCost", "get_expedision_cost_error=%v", err) return 0, err } + logHpp("GetTotalProductionCost", "cost_expedision=%f", costExpedision) costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate) if err != nil { + logHpp("GetTotalProductionCost", "get_budget_kandang_laying_error=%v", err) return 0, err } + logHpp("GetTotalProductionCost", "cost_budget=%f", costBudget) - return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil + // fmt.Println(costBudget, costExpedision, costOvk, costFeed, costPullet, depresiasiTransfer) + + // depresiasiTransfer = 0 + + total := depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget + logHpp("GetTotalProductionCost", "done total=%f", total) + return total, nil } func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + logHpp("GetBudgetKandangLaying", "start project_flock_kandang_id=%d end_date=%s", projectFlockKandangId, formatTimePtr(endDate)) // if date == nil { // now := time.Now() // date = &now // } if s.hppRepo == nil { + logHpp("GetBudgetKandangLaying", "repo_nil return=0") return 0, nil } projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId) if err != nil { + logHpp("GetBudgetKandangLaying", "get_project_flock_id_error=%v", err) return 0, err } + logHpp("GetBudgetKandangLaying", "project_flock_id=%d", projectFlockId) projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId) if err != nil { + logHpp("GetBudgetKandangLaying", "get_project_flock_kandang_ids_error=%v", err) return 0, err } + logHpp("GetBudgetKandangLaying", "project_flock_kandang_ids=%v", projectFlockKandangIds) eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate) if err != nil { + logHpp("GetBudgetKandangLaying", "get_egg_produksi_pieces_flock_error=%v", err) return 0, err } + logHpp("GetBudgetKandangLaying", "egg_produksi_pieces_flock=%f", eggProduksiPiecesFlock) eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { + logHpp("GetBudgetKandangLaying", "get_egg_produksi_pieces_kandang_error=%v", err) return 0, err } + logHpp("GetBudgetKandangLaying", "egg_produksi_pieces_kandang=%f", eggProduksiPiecesKandang) totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId) if err != nil { + logHpp("GetBudgetKandangLaying", "get_budget_cost_error=%v", err) return 0, err } + logHpp("GetBudgetKandangLaying", "total_budget_cost=%f", totalBudgetCost) if eggProduksiPiecesFlock == 0 { + logHpp("GetBudgetKandangLaying", "egg_produksi_pieces_flock_zero return=0") return 0, nil } - return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil + result := (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock + logHpp("GetBudgetKandangLaying", "done result=%f", result) + return result, nil } func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - // if endDate == nil { - // now := time.Now() - // endDate = &now - // } + logHpp("GetDepresiasiTransfer", "start project_flock_kandang_id=%d end_date=%s", projectFlockKandangId, formatTimePtr(endDate)) + if endDate == nil { + now := time.Now() + endDate = &now + } + logHpp("GetDepresiasiTransfer", "normalized_end_date=%s", formatTimePtr(endDate)) if s.hppRepo == nil { + logHpp("GetDepresiasiTransfer", "repo_nil return=0") return 0, nil } sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) if err != nil { + logHpp("GetDepresiasiTransfer", "get_transfer_source_summary_error=%v", err) return 0, err } + logHpp("GetDepresiasiTransfer", "source_project_flock_id=%d transfer_total_qty=%f", sourceProjectFlockID, transferTotalQty) + if sourceProjectFlockID == 0 || transferTotalQty <= 0 { + logHpp("GetDepresiasiTransfer", "use_manual_fallback=true") + result, fallbackErr := s.getManualDepresiasiTransferFallback(projectFlockKandangId) + if fallbackErr != nil { + logHpp("GetDepresiasiTransfer", "manual_fallback_error=%v", fallbackErr) + return 0, fallbackErr + } + logHpp("GetDepresiasiTransfer", "done_fallback result=%f", result) + return result, nil + } kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) if err != nil { + logHpp("GetDepresiasiTransfer", "get_project_flock_kandang_ids_error=%v", err) return 0, err } + logHpp("GetDepresiasiTransfer", "kandang_ids_growing=%v", kandangIDsGrowing) totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) if err != nil { + logHpp("GetDepresiasiTransfer", "get_total_population_error=%v", err) return 0, err } + logHpp("GetDepresiasiTransfer", "total_population_flock_growing=%f", totalPopulationFlockGrowing) if totalPopulationFlockGrowing == 0 { + logHpp("GetDepresiasiTransfer", "total_population_flock_growing_zero return=0") return 0, nil } totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, endDate) if err != nil { + logHpp("GetDepresiasiTransfer", "get_total_depresiasi_flock_growing_error=%v", err) return 0, err } + logHpp("GetDepresiasiTransfer", "total_depresiasi_flock_growing=%f", totalDepresiasiFlockGrowing) - return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil + result := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing + logHpp("GetDepresiasiTransfer", "done result=%f", result) + return result, nil +} + +func (s *hppService) getManualDepresiasiTransferFallback(projectFlockKandangId uint) (float64, error) { + logHpp("getManualDepresiasiTransferFallback", "start project_flock_kandang_id=%d", projectFlockKandangId) + projectFlockID, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId) + if err != nil { + logHpp("getManualDepresiasiTransferFallback", "get_project_flock_id_error=%v", err) + return 0, err + } + logHpp("getManualDepresiasiTransferFallback", "project_flock_id=%d", projectFlockID) + if projectFlockID == 0 { + logHpp("getManualDepresiasiTransferFallback", "project_flock_id_zero return=0") + return 0, nil + } + + manualCost, err := s.hppRepo.GetManualDepreciationCostByProjectFlockID(context.Background(), projectFlockID) + if err != nil { + logHpp("getManualDepresiasiTransferFallback", "get_manual_depreciation_cost_error=%v", err) + return 0, err + } + logHpp("getManualDepresiasiTransferFallback", "manual_cost=%f", manualCost) + if manualCost <= 0 { + logHpp("getManualDepresiasiTransferFallback", "manual_cost_non_positive return=0") + return 0, nil + } + + kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockID) + if err != nil { + logHpp("getManualDepresiasiTransferFallback", "get_project_flock_kandang_ids_error=%v", err) + return 0, err + } + logHpp("getManualDepresiasiTransferFallback", "kandang_ids=%v", kandangIDs) + if len(kandangIDs) == 0 { + logHpp("getManualDepresiasiTransferFallback", "kandang_ids_empty return=0") + return 0, nil + } + + totalUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDs) + if err != nil { + logHpp("getManualDepresiasiTransferFallback", "get_total_usage_qty_error=%v", err) + return 0, err + } + logHpp("getManualDepresiasiTransferFallback", "total_usage_qty=%f", totalUsageQty) + if totalUsageQty <= 0 { + logHpp("getManualDepresiasiTransferFallback", "total_usage_qty_non_positive return=0") + return 0, nil + } + + kandangUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId}) + if err != nil { + logHpp("getManualDepresiasiTransferFallback", "get_kandang_usage_qty_error=%v", err) + return 0, err + } + logHpp("getManualDepresiasiTransferFallback", "kandang_usage_qty=%f", kandangUsageQty) + if kandangUsageQty <= 0 { + logHpp("getManualDepresiasiTransferFallback", "kandang_usage_qty_non_positive return=0") + return 0, nil + } + + result := manualCost * (kandangUsageQty / totalUsageQty) + logHpp("getManualDepresiasiTransferFallback", "done result=%f", result) + return result, nil } func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { + logHpp("GetHppEstimationDanRealisasi", "start total_production_cost=%f project_flock_kandang_id=%d start_date=%s end_date=%s", totalProductionCost, projectFlockKandangId, formatTimePtr(startDate), formatTimePtr(endDate)) if s.hppRepo == nil { + logHpp("GetHppEstimationDanRealisasi", "repo_nil return_empty_response") return &HppCostResponse{}, nil } estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { + logHpp("GetHppEstimationDanRealisasi", "get_egg_produksi_error=%v", err) return nil, err } + logHpp("GetHppEstimationDanRealisasi", "estim_pieces=%f estim_weight_kg=%f", estimPieces, estimWeightKg) realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate) if err != nil { + logHpp("GetHppEstimationDanRealisasi", "get_egg_terjual_error=%v", err) return nil, err } + logHpp("GetHppEstimationDanRealisasi", "real_pieces=%f real_weight_kg=%f", realPieces, realWeightKg) estimation := HppCostDetail{ Total: totalProductionCost, @@ -248,6 +403,7 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p if estimPieces > 0 { estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces) } + logHpp("GetHppEstimationDanRealisasi", "estimation=%+v", estimation) real := HppCostDetail{ Total: totalProductionCost, @@ -260,13 +416,29 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p if realPieces > 0 { real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) } + logHpp("GetHppEstimationDanRealisasi", "real=%+v", real) - return &HppCostResponse{ + result := &HppCostResponse{ Estimation: estimation, Real: real, - }, nil + } + logHpp("GetHppEstimationDanRealisasi", "done response=%+v", *result) + return result, nil } func roundToTwoDecimals(value float64) float64 { - return math.Round(value*100) / 100 + result := math.Round(value*100) / 100 + logHpp("roundToTwoDecimals", "input=%f output=%f", value, result) + return result +} + +func formatTimePtr(value *time.Time) string { + if value == nil { + return "" + } + return value.Format(time.RFC3339) +} + +func logHpp(method, format string, args ...any) { + log.Printf("[HPP][%s] "+format, append([]any{method}, args...)...) } diff --git a/internal/common/service/depreciation_snapshot_invalidator.service.go b/internal/common/service/depreciation_snapshot_invalidator.service.go new file mode 100644 index 00000000..309cd844 --- /dev/null +++ b/internal/common/service/depreciation_snapshot_invalidator.service.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +const farmDepreciationSnapshotTable = "farm_depreciation_snapshots" + +func NormalizeDateOnlyUTC(value time.Time) time.Time { + if value.IsZero() { + return value + } + v := value.UTC() + return time.Date(v.Year(), v.Month(), v.Day(), 0, 0, 0, 0, time.UTC) +} + +func MinNonZeroDateOnlyUTC(values ...time.Time) time.Time { + var out time.Time + for _, value := range values { + if value.IsZero() { + continue + } + normalized := NormalizeDateOnlyUTC(value) + if out.IsZero() || normalized.Before(out) { + out = normalized + } + } + return out +} + +func InvalidateFarmDepreciationSnapshotsFromDate(ctx context.Context, db *gorm.DB, farmIDs []uint, fromDate time.Time) error { + if db == nil { + return nil + } + if fromDate.IsZero() { + return nil + } + + fromDate = NormalizeDateOnlyUTC(fromDate) + query := db.WithContext(ctx). + Table(farmDepreciationSnapshotTable). + Where("period_date >= ?", fromDate) + if len(farmIDs) > 0 { + query = query.Where("project_flock_id IN ?", farmIDs) + } + return query.Delete(nil).Error +} + +func ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx context.Context, db *gorm.DB, pfkIDs []uint) ([]uint, error) { + if db == nil || len(pfkIDs) == 0 { + return []uint{}, nil + } + + var projectFlockIDs []uint + if err := db.WithContext(ctx). + Table("project_flock_kandangs"). + Distinct("project_flock_id"). + Where("id IN ?", pfkIDs). + Pluck("project_flock_id", &projectFlockIDs).Error; err != nil { + return nil, err + } + + return projectFlockIDs, nil +} + +func ResolveProjectFlockIDsByExpenseID(ctx context.Context, db *gorm.DB, expenseID uint) ([]uint, error) { + if db == nil || expenseID == 0 { + return []uint{}, nil + } + + query := ` +WITH direct_farms AS ( + SELECT DISTINCT pfk.project_flock_id + FROM expense_nonstocks ens + JOIN project_flock_kandangs pfk ON pfk.id = ens.project_flock_kandang_id + WHERE ens.expense_id = @expense_id +), +json_farms AS ( + SELECT DISTINCT (jsonb_array_elements_text(e.project_flock_id::jsonb))::bigint AS project_flock_id + FROM expenses e + WHERE e.id = @expense_id + AND e.project_flock_id IS NOT NULL +) +SELECT DISTINCT project_flock_id +FROM ( + SELECT project_flock_id FROM direct_farms + UNION ALL + SELECT project_flock_id FROM json_farms +) x +` + + var ids []uint + if err := db.WithContext(ctx).Raw(query, map[string]any{ + "expense_id": expenseID, + }).Scan(&ids).Error; err != nil { + return nil, err + } + + return ids, nil +} diff --git a/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..down.sql b/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..down.sql new file mode 100644 index 00000000..b3ba5c34 --- /dev/null +++ b/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..down.sql @@ -0,0 +1,6 @@ +ALTER TABLE kandangs + DROP COLUMN IF EXISTS house_type; + +DROP TABLE IF EXISTS house_depreciation_standards; + +DROP TYPE IF EXISTS house_type_enum; diff --git a/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..up.sql b/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..up.sql new file mode 100644 index 00000000..6301b91f --- /dev/null +++ b/internal/database/migrations/20260415050222_create_house_depreciation_standards_and_add_house_type_to_kandangs..up.sql @@ -0,0 +1,18 @@ +CREATE TYPE house_type_enum AS ENUM ('open_house', 'close_house'); + +CREATE TABLE house_depreciation_standards ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100), + effective_date DATE, + house_type house_type_enum NOT NULL, + day INT NOT NULL + CHECK (day >= 0), + depreciation_percent NUMERIC(15, 6) NOT NULL + CHECK (depreciation_percent >= 0 AND depreciation_percent <= 100), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT house_depreciation_standards_house_type_day_unique UNIQUE (house_type, day) +); + +ALTER TABLE kandangs + ADD COLUMN house_type house_type_enum; diff --git a/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.down.sql b/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.down.sql new file mode 100644 index 00000000..59e44914 --- /dev/null +++ b/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_project_flock_id; +DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_period_date; +DROP TABLE IF EXISTS farm_depreciation_snapshots; + diff --git a/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.up.sql b/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.up.sql new file mode 100644 index 00000000..450edc90 --- /dev/null +++ b/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS farm_depreciation_snapshots ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL + REFERENCES project_flocks(id) + ON UPDATE CASCADE + ON DELETE CASCADE, + period_date DATE NOT NULL, + depreciation_percent_effective NUMERIC(15, 6) NOT NULL DEFAULT 0, + depreciation_value NUMERIC(18, 3) NOT NULL DEFAULT 0, + pullet_cost_day_n_total NUMERIC(18, 3) NOT NULL DEFAULT 0, + components JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT farm_depreciation_snapshots_unique UNIQUE (project_flock_id, period_date) +); + +CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_period_date + ON farm_depreciation_snapshots (period_date); + +CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_project_flock_id + ON farm_depreciation_snapshots (project_flock_id); + diff --git a/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.down.sql b/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.down.sql new file mode 100644 index 00000000..62fa4007 --- /dev/null +++ b/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_project_flock_id; +DROP TABLE IF EXISTS farm_depreciation_manual_inputs; diff --git a/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.up.sql b/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.up.sql new file mode 100644 index 00000000..fd07e217 --- /dev/null +++ b/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS farm_depreciation_manual_inputs ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL + REFERENCES project_flocks(id) + ON UPDATE CASCADE + ON DELETE CASCADE, + total_cost NUMERIC(18, 3) NOT NULL DEFAULT 0 + CHECK (total_cost >= 0), + note TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT farm_depreciation_manual_inputs_unique UNIQUE (project_flock_id) +); + +CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_project_flock_id + ON farm_depreciation_manual_inputs (project_flock_id); diff --git a/internal/entities/farm_depreciation_manual_input.go b/internal/entities/farm_depreciation_manual_input.go new file mode 100644 index 00000000..2e10ee56 --- /dev/null +++ b/internal/entities/farm_depreciation_manual_input.go @@ -0,0 +1,17 @@ +package entities + +import "time" + +type FarmDepreciationManualInput struct { + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_manual_inputs_unique"` + TotalCost float64 `gorm:"type:numeric(18,3);not null;default:0"` + Note *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` +} + +func (FarmDepreciationManualInput) TableName() string { + return "farm_depreciation_manual_inputs" +} diff --git a/internal/entities/farm_depreciation_snapshot.go b/internal/entities/farm_depreciation_snapshot.go new file mode 100644 index 00000000..24ce72b9 --- /dev/null +++ b/internal/entities/farm_depreciation_snapshot.go @@ -0,0 +1,21 @@ +package entities + +import ( + "time" +) + +type FarmDepreciationSnapshot struct { + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:1"` + PeriodDate time.Time `gorm:"type:date;not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:2"` + DepreciationPercentEffective float64 `gorm:"type:numeric(15,6);not null;default:0"` + DepreciationValue float64 `gorm:"type:numeric(18,3);not null;default:0"` + PulletCostDayNTotal float64 `gorm:"type:numeric(18,3);not null;default:0"` + Components []byte `gorm:"type:jsonb;default:'{}'::jsonb"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (FarmDepreciationSnapshot) TableName() string { + return "farm_depreciation_snapshots" +} diff --git a/internal/entities/house_depreciation_standard.go b/internal/entities/house_depreciation_standard.go new file mode 100644 index 00000000..9300c94b --- /dev/null +++ b/internal/entities/house_depreciation_standard.go @@ -0,0 +1,16 @@ +package entities + +import "time" + +type HouseDepreciationStandard struct { + Id uint `gorm:"primaryKey"` + HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"` + DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"` + DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (HouseDepreciationStandard) TableName() string { + return "house_depreciation_standards" +} diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 47daf0bf..67ab7678 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -10,6 +10,7 @@ type Kandang struct { Id uint `gorm:"primaryKey"` Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` Status string `gorm:"type:varchar(50);not null"` + HouseType *string `gorm:"type:house_type_enum"` LocationId uint `gorm:"not null"` KandangGroupId uint `gorm:"not null"` Capacity float64 `gorm:"not null"` diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index fa8374ba..f9d23d3e 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -47,13 +47,14 @@ const ( P_ApprovalGetAll = "lti.approval.list" ) const ( - P_ReportExpenseGetAll = "lti.repport.expense.list" - P_ReportDeliveryGetAll = "lti.repport.delivery.list" - P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list" - P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list" - P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" - P_ReportProductionResultGetAll = "lti.repport.production_result.list" - P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list" + P_ReportExpenseGetAll = "lti.repport.expense.list" + P_ReportExpenseDepreciationManage = "lti.repport.expense.depreciation.manage" + P_ReportDeliveryGetAll = "lti.repport.delivery.list" + P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list" + P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list" + P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" + P_ReportProductionResultGetAll = "lti.repport.production_result.list" + P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list" ) const ( diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 5e6fc420..57593e59 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -358,6 +359,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen if err != nil { return nil, err } + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, uint(expense.Id), expenseDate, nil) return responseDTO, nil } @@ -385,6 +387,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } updateBody := make(map[string]any) + var requestedTransactionDate *time.Time if req.TransactionDate != nil { expenseDate, err := utils.ParseDateString(*req.TransactionDate) @@ -392,6 +395,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") } updateBody["transaction_date"] = expenseDate + requestedTransactionDate = &expenseDate } if req.Category != nil { @@ -429,6 +433,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return responseDTO, nil } + var invalidationFromDate time.Time + var invalidationFarmIDs []uint err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { expenseRepoTx := repository.NewExpenseRepository(tx) @@ -446,6 +452,16 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { return err } + oldFarmIDs, resolveOldFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id) + if resolveOldFarmErr != nil { + s.Log.Warnf("Failed to resolve old expense farm ids for invalidation (expense_id=%d): %+v", id, resolveOldFarmErr) + } + invalidationFarmIDs = append(invalidationFarmIDs, oldFarmIDs...) + + invalidationFromDate = currentExpense.TransactionDate + if requestedTransactionDate != nil { + invalidationFromDate = commonSvc.MinNonZeroDateOnlyUTC(currentExpense.TransactionDate, *requestedTransactionDate) + } categoryChanged := false var newCategory string if req.Category != nil && *req.Category != currentExpense.Category { @@ -631,6 +647,12 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } + newFarmIDs, resolveNewFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id) + if resolveNewFarmErr != nil { + s.Log.Warnf("Failed to resolve new expense farm ids for invalidation (expense_id=%d): %+v", id, resolveNewFarmErr) + } + invalidationFarmIDs = append(invalidationFarmIDs, newFarmIDs...) + return nil }) @@ -645,6 +667,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err != nil { return nil, err } + s.invalidateDepreciationSnapshots(c.Context(), nil, invalidationFarmIDs, invalidationFromDate) return responseDTO, nil } @@ -671,6 +694,10 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error { if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { return err } + farmIDs, resolveFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), s.Repository.DB(), idUint) + if resolveFarmErr != nil { + s.Log.Warnf("Failed to resolve expense farm ids before delete (expense_id=%d): %+v", idUint, resolveFarmErr) + } if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Expense not found for ID %d: %+v", id, err) @@ -680,6 +707,8 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error { return err } s.Log.Infof("Successfully deleted expense with ID %d", id) + invalidationFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate) + s.invalidateDepreciationSnapshots(c.Context(), nil, farmIDs, invalidationFromDate) return nil } @@ -800,6 +829,8 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va if err != nil { return nil, err } + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate) + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) return responseDTO, nil } @@ -857,6 +888,13 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( if err != nil { return nil, err } + expense, expenseErr := s.Repository.GetByID(c.Context(), id, nil) + if expenseErr != nil { + s.Log.Warnf("Failed to load expense for depreciation invalidation after complete (expense_id=%d): %+v", id, expenseErr) + } else { + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate) + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, id, invalidateFromDate, nil) + } return responseDTO, nil } @@ -884,6 +922,12 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { return nil, err } + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate) + if req.RealizationDate != nil { + if parsedDate, parseErr := utils.ParseDateString(*req.RealizationDate); parseErr == nil { + invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, parsedDate) + } + } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") @@ -996,6 +1040,7 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va if err != nil { return nil, err } + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) return responseDTO, nil } @@ -1057,6 +1102,7 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } var results []expenseDto.ExpenseDetailDTO + invalidateFromDateByExpenseID := make(map[uint]time.Time) err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { @@ -1069,6 +1115,17 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, ); err != nil { return err } + expenseForInvalidation, err := expenseRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense") + } + invalidateFromDateByExpenseID[id] = commonSvc.MinNonZeroDateOnlyUTC( + expenseForInvalidation.TransactionDate, + expenseForInvalidation.RealizationDate, + ) latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -1170,10 +1227,73 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed approve expenses") } + for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID { + s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) + } return results, nil } +func (s *expenseService) invalidateDepreciationSnapshotsByExpense( + ctx context.Context, + tx *gorm.DB, + expenseID uint, + fromDate time.Time, + fallbackFarmIDs []uint, +) { + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + + farmIDs := append([]uint{}, fallbackFarmIDs...) + if expenseID != 0 { + resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByExpenseID(ctx, targetDB, expenseID) + if err != nil { + s.Log.Warnf("Failed to resolve expense farm ids for invalidation (expense_id=%d): %+v", expenseID, err) + } else { + farmIDs = append(farmIDs, resolvedFarmIDs...) + } + } + s.invalidateDepreciationSnapshots(ctx, tx, farmIDs, fromDate) +} + +func (s *expenseService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + farmIDs []uint, + fromDate time.Time, +) { + if fromDate.IsZero() { + return + } + + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + farmIDs = utils.UniqueUintSlice(farmIDs) + if len(farmIDs) == 0 { + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots globally (from=%s): %+v", + fromDate.Format("2006-01-02"), + err, + ) + } + return + } + + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v", + farmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } +} + func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) { expenseRepoTx := repository.NewExpenseRepository(ctx) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 1bade0a9..09c617f8 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -419,6 +419,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if len(result) == 0 { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load created chickins") } + invalidateFromDate := time.Time{} + for i := range result { + invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, result[i].ChickInDate) + } + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{req.ProjectFlockKandangId}, invalidateFromDate) return result, nil } @@ -462,6 +467,8 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err != nil { return nil, err } + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(chickin.ChickInDate, updated.ChickInDate) + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{updated.ProjectFlockKandangId}, invalidateFromDate) if updated.UsageQty > 0 { if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil { @@ -566,6 +573,7 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { consumeAllocAfter, traceAllocAfter, ) + s.invalidateDepreciationSnapshots(c.Context(), tx, []uint{lockedChickin.ProjectFlockKandangId}, lockedChickin.ChickInDate) return nil }) @@ -1160,6 +1168,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if action == entity.ApprovalActionApproved { step = utils.ChickinStepDisetujui } + invalidateFromByPFK := make(map[uint]time.Time, len(approvableIDs)) err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil { @@ -1204,6 +1213,12 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get chickins for approval %d", approvableID)) } + for _, chickin := range chickins { + invalidateFromByPFK[approvableID] = commonSvc.MinNonZeroDateOnlyUTC( + invalidateFromByPFK[approvableID], + chickin.ChickInDate, + ) + } kandangForApproval, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) if err != nil { @@ -1281,6 +1296,12 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID)) } + for _, chickin := range chickins { + invalidateFromByPFK[approvableID] = commonSvc.MinNonZeroDateOnlyUTC( + invalidateFromByPFK[approvableID], + chickin.ChickInDate, + ) + } if len(chickins) == 0 { continue @@ -1328,6 +1349,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") } + for projectFlockKandangID, invalidateFromDate := range invalidateFromByPFK { + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{projectFlockKandangID}, invalidateFromDate) + } updated := make([]entity.ProjectChickin, 0) for _, kandangID := range approvableIDs { @@ -1837,6 +1861,57 @@ func normalizeDateOnlyUTC(value time.Time) time.Time { return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) } +func (s chickinService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + projectFlockKandangIDs []uint, + fromDate time.Time, +) { + if fromDate.IsZero() { + return + } + + projectFlockKandangIDs = uniqueUint(projectFlockKandangIDs) + if len(projectFlockKandangIDs) == 0 { + return + } + + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + + farmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, projectFlockKandangIDs) + if err != nil { + s.Log.Warnf( + "Failed to resolve farm ids for chickin depreciation invalidation (pfk_ids=%v): %+v", + projectFlockKandangIDs, + err, + ) + farmIDs = nil + } + + if len(farmIDs) == 0 { + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots globally (from=%s): %+v", + fromDate.Format("2006-01-02"), + err, + ) + } + return + } + + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v", + farmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } +} + func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error { if productWarehouseID == 0 { return nil diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go index d48d9990..d17259c0 100644 --- a/internal/modules/production/project-flock-kandangs/route.go +++ b/internal/modules/production/project-flock-kandangs/route.go @@ -13,11 +13,11 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo ctrl := controller.NewProjectFlockKandangController(s) route := v1.Group("/project-flock-kandangs") - route.Use(m.Auth(u)) - route.Get("/",m.RequirePermissions(m.P_ProjectFlockKandangsGetAll), ctrl.GetAll) - route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) + // route.Use(m.Auth(u)) + route.Get("/", ctrl.GetAll) + route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) // route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing) // route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing) - route.Post("/:id/closing",m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing) + route.Post("/:id/closing", m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing) route.Get("/:id/closing/check", m.RequirePermissions(m.P_ProjectFlockKandangsCheckClosing), ctrl.CheckClosing) } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5c4d6a9c..d3221872 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -517,7 +517,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, transactionErr } - return s.GetOne(c, createdRecording.Id) + created, err := s.GetOne(c, createdRecording.Id) + if err != nil { + return nil, err + } + if created != nil { + s.invalidateDepreciationSnapshots(c.Context(), nil, created.ProjectFlockKandangId, created.RecordDatetime) + } + return created, nil } func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { @@ -848,6 +855,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := recordingutil.AttachProductionStandards(ctx, s.Repository.DB(), false, s.Log, updatedRecording); err != nil { return nil, err } + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(recordingEntity.RecordDatetime, updatedRecording.RecordDatetime) + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + updatedRecording.ProjectFlockKandangId, + invalidateFromDate, + ) return updatedRecording, nil } @@ -965,6 +979,12 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent if err != nil { return nil, err } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + recording.ProjectFlockKandangId, + recording.RecordDatetime, + ) updated = append(updated, *recording) } @@ -985,7 +1005,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { } note := recordingutil.RecordingNote("Delete", id) - return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err = s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { recording, err := s.Repository.WithTx(tx).GetByID(ctx, id, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -1029,9 +1049,60 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err) return err } + s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime) return nil }) + if err != nil { + return err + } + return nil +} + +func (s recordingService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + projectFlockKandangID uint, + fromDate time.Time, +) { + if projectFlockKandangID == 0 || fromDate.IsZero() { + return + } + + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + + farmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, []uint{projectFlockKandangID}) + if err != nil { + s.Log.Warnf( + "Failed to resolve farm for recording depreciation invalidation (pfk=%d): %+v", + projectFlockKandangID, + err, + ) + farmIDs = nil + } + + if len(farmIDs) == 0 { + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots globally (from=%s): %+v", + fromDate.Format("2006-01-02"), + err, + ) + } + return + } + + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v", + farmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } } func (s *recordingService) resolveRecordingCategory(ctx context.Context, recording *entity.Recording) (string, error) { diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index ce267544..c1748cc8 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -377,6 +377,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) if err != nil { return nil, err } + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{req.TargetProjectFlockId}, transferDate) return laying_transfer, nil } @@ -588,6 +589,13 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, } layingTransfer, _, err := s.GetOne(c, id) + invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(existingTransfer.TransferDate, transferDate) + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{existingTransfer.ToProjectFlockId, req.TargetProjectFlockId}, + invalidateFromDate, + ) return layingTransfer, err } @@ -661,6 +669,7 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to delete transferLaying: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") } + s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{transfer.ToProjectFlockId}, transfer.TransferDate) return nil } @@ -798,6 +807,14 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( if err != nil { return nil, err } + if transfer != nil { + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{transfer.ToProjectFlockId}, + resolveDepreciationEffectiveDateForTransfer(transfer), + ) + } updated = append(updated, *transfer) } @@ -837,6 +854,14 @@ func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTra if err != nil { return nil, err } + if transfer != nil { + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{transfer.ToProjectFlockId}, + resolveDepreciationEffectiveDateForTransfer(transfer), + ) + } return transfer, nil } @@ -873,6 +898,14 @@ func (s transferLayingService) ExecuteWithBusinessDate(c *fiber.Ctx, id uint, bu if err != nil { return nil, err } + if transfer != nil { + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{transfer.ToProjectFlockId}, + resolveDepreciationEffectiveDateForTransfer(transfer), + ) + } return transfer, nil } @@ -1226,6 +1259,14 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT if err != nil { return nil, err } + if transfer != nil { + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + []uint{transfer.ToProjectFlockId}, + resolveDepreciationEffectiveDateForTransfer(transfer), + ) + } return transfer, nil } @@ -1678,6 +1719,43 @@ func normalizeDateOnlyUTC(value time.Time) time.Time { return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) } +func resolveDepreciationEffectiveDateForTransfer(transfer *entity.LayingTransfer) time.Time { + if transfer == nil { + return time.Time{} + } + if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { + return *transfer.EffectiveMoveDate + } + if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() { + return *transfer.EconomicCutoffDate + } + return transfer.TransferDate +} + +func (s transferLayingService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + farmIDs []uint, + fromDate time.Time, +) { + if fromDate.IsZero() { + return + } + targetDB := s.Repository.DB() + if tx != nil { + targetDB = tx + } + uniqueFarmIDs := utils.UniqueUintSlice(farmIDs) + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, uniqueFarmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate farm depreciation snapshots (farms=%v, from=%s): %+v", + uniqueFarmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } +} + func isLegacyTransfer(transfer *entity.LayingTransfer) bool { if transfer == nil { return false diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 5324d60f..ec8617b8 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -675,6 +675,12 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase if err := s.attachLatestApproval(c.Context(), created); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err) } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(created), + resolvePurchaseDepreciationInvalidateDate(created, created.Items, now), + ) return created, nil } @@ -826,6 +832,12 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(updated), + resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()), + ) return updated, nil } @@ -934,6 +946,12 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(updated), + resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, now), + ) return updated, nil } @@ -1421,6 +1439,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } + invalidateFromDate := resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()) + if earliestReceived != nil { + invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, *earliestReceived) + } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(updated), + invalidateFromDate, + ) receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared)) for _, prep := range prepared { @@ -1628,6 +1656,12 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } + s.invalidateDepreciationSnapshots( + ctx, + nil, + collectPFKIDsFromPurchaseItems(itemsToDelete), + resolvePurchaseDepreciationInvalidateDate(purchase, itemsToDelete, time.Now().UTC()), + ) return updated, nil } @@ -1721,6 +1755,12 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { return utils.Internal("Failed to sync expense") } } + s.invalidateDepreciationSnapshots( + ctx, + nil, + collectPFKIDsFromPurchaseItems(itemsToDelete), + resolvePurchaseDepreciationInvalidateDate(purchase, itemsToDelete, time.Now().UTC()), + ) return nil } @@ -2391,7 +2431,17 @@ func (s *purchaseService) rejectAndReload( if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil { return nil, err } - return s.loadPurchase(c.Context(), purchaseID) + updated, err := s.loadPurchase(c.Context(), purchaseID) + if err != nil { + return nil, err + } + s.invalidateDepreciationSnapshots( + c.Context(), + nil, + collectPFKIDsFromPurchase(updated), + resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()), + ) + return updated, nil } func (s *purchaseService) loadPurchase( ctx context.Context, @@ -2522,10 +2572,17 @@ func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Contex } func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { + if p == nil { + return nil + } + return collectPFKIDsFromPurchaseItems(p.Items) +} + +func collectPFKIDsFromPurchaseItems(items []entity.PurchaseItem) []uint { seen := make(map[uint]struct{}) ids := make([]uint, 0) - for _, item := range p.Items { + for _, item := range items { if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 { continue } @@ -2538,6 +2595,82 @@ func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { } return ids } + +func resolvePurchaseDepreciationInvalidateDate( + purchase *entity.Purchase, + items []entity.PurchaseItem, + fallback time.Time, +) time.Time { + fromDate := time.Time{} + if purchase != nil { + fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, purchase.CreatedAt) + if purchase.PoDate != nil { + fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, *purchase.PoDate) + } + } + for _, item := range items { + if item.ReceivedDate == nil { + continue + } + fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, *item.ReceivedDate) + } + if fromDate.IsZero() { + fromDate = fallback + } + return fromDate +} + +func (s *purchaseService) invalidateDepreciationSnapshots( + ctx context.Context, + tx *gorm.DB, + projectFlockKandangIDs []uint, + fromDate time.Time, +) { + if fromDate.IsZero() { + return + } + projectFlockKandangIDs = utils.UniqueUintSlice(projectFlockKandangIDs) + + targetDB := s.PurchaseRepo.DB() + if tx != nil { + targetDB = tx + } + + var farmIDs []uint + if len(projectFlockKandangIDs) > 0 { + resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, projectFlockKandangIDs) + if err != nil { + s.Log.Warnf( + "Failed to resolve farm ids for purchase depreciation invalidation (pfk_ids=%v): %+v", + projectFlockKandangIDs, + err, + ) + } else { + farmIDs = resolvedFarmIDs + } + } + + if len(farmIDs) == 0 { + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots globally (from=%s): %+v", + fromDate.Format("2006-01-02"), + err, + ) + } + return + } + + if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil { + s.Log.Warnf( + "Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v", + farmIDs, + fromDate.Format("2006-01-02"), + err, + ) + } +} + func (s *purchaseService) ensureProjectFlockNotClosedForPurchase( ctx context.Context, purchase *entity.Purchase, diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 5d85a53e..181990a1 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -90,6 +90,75 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { }) } +func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error { + rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx) + if err != nil { + return err + } + + resp := struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta dto.ExpenseDepreciationMetaDTO `json:"meta"` + Data []dto.ExpenseDepreciationRowDTO `json:"data"` + }{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get expense depreciation report successfully", + Meta: *meta, + Data: rows, + } + + return ctx.Status(fiber.StatusOK).JSON(resp) +} + +func (c *RepportController) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) error { + rows, meta, err := c.RepportService.GetExpenseDepreciationManualInputs(ctx) + if err != nil { + return err + } + + resp := struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta dto.ExpenseDepreciationMetaDTO `json:"meta"` + Data []dto.ExpenseDepreciationManualInputRowDTO `json:"data"` + }{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get expense depreciation manual inputs successfully", + Meta: *meta, + Data: rows, + } + + return ctx.Status(fiber.StatusOK).JSON(resp) +} + +func (c *RepportController) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx) error { + req := new(validation.ExpenseDepreciationManualInputUpsert) + if err := ctx.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if err := m.EnsureProjectFlockAccess(ctx, c.RepportService.DB(), req.ProjectFlockID); err != nil { + return err + } + + result, err := c.RepportService.UpsertExpenseDepreciationManualInput(ctx, req) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Upsert expense depreciation manual input successfully", + Data: result, + }) +} + func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { query := &validation.MarketingQuery{ Page: ctx.QueryInt("page", 1), diff --git a/internal/modules/repports/dto/repportExpenseDepreciation.dto.go b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go new file mode 100644 index 00000000..a968da9c --- /dev/null +++ b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go @@ -0,0 +1,43 @@ +package dto + +type ExpenseDepreciationFiltersDTO struct { + AreaID string `json:"area_id"` + LocationID string `json:"location_id"` + ProjectFlockID string `json:"project_flock_id"` + Period string `json:"period"` +} + +type ExpenseDepreciationMetaDTO struct { + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int64 `json:"total_pages"` + TotalResults int64 `json:"total_results"` + Filters ExpenseDepreciationFiltersDTO `json:"filters"` +} + +type ExpenseDepreciationRowDTO struct { + ProjectFlockID int64 `json:"project_flock_id"` + FarmName string `json:"farm_name"` + Period string `json:"period"` + DepreciationPercentEffective float64 `json:"depreciation_percent_effective"` + DepreciationValue float64 `json:"depreciation_value"` + PulletCostDayNTotal float64 `json:"pullet_cost_day_n_total"` + Components any `json:"components"` +} + +type ExpenseDepreciationManualInputRowDTO struct { + ID int64 `json:"id"` + ProjectFlockID int64 `json:"project_flock_id"` + FarmName string `json:"farm_name"` + TotalCost float64 `json:"total_cost"` + Note *string `json:"note"` +} + +func NewExpenseDepreciationFiltersDTO(area, location, projectFlockID, period string) ExpenseDepreciationFiltersDTO { + return ExpenseDepreciationFiltersDTO{ + AreaID: area, + LocationID: location, + ProjectFlockID: projectFlockID, + Period: period, + } +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 9a64b806..59fd4b89 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -36,6 +36,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) + expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) customerRepository := customerRepo.NewCustomerRepository(db) @@ -45,7 +46,27 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalSvc := approvalService.NewApprovalService(approvalRepository) hppSvc := approvalService.NewHppService(hppCostRepository) - repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, hppSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository) + repportService := sRepport.NewRepportService( + db, + validate, + expenseRealizationRepository, + expenseDepreciationRepository, + marketingDeliveryProductRepository, + purchaseRepository, + chickinRepository, + recordingRepository, + approvalSvc, + hppSvc, + hppCostRepository, + purchaseSupplierRepository, + debtSupplierRepository, + hppPerKandangRepository, + productionResultRepository, + customerPaymentRepository, + customerRepository, + standardGrowthDetailRepository, + productionStandardDetailRepository, + ) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/expense_depreciation.repository.go b/internal/modules/repports/repositories/expense_depreciation.repository.go new file mode 100644 index 00000000..c9897a1a --- /dev/null +++ b/internal/modules/repports/repositories/expense_depreciation.repository.go @@ -0,0 +1,326 @@ +package repositories + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type FarmDepreciationCandidateRow struct { + ProjectFlockID uint + FarmName string +} + +type FarmDepreciationLatestTransferRow struct { + ProjectFlockID uint + FarmName string + ProjectFlockKandangID uint + KandangID uint + KandangName string + HouseType *string + SourceProjectFlockID uint + TransferDate time.Time + TransferQty float64 + TransferID uint +} + +type FarmDepreciationManualInputRow struct { + Id uint + ProjectFlockID uint + FarmName string + TotalCost float64 + Note *string +} + +type houseDepreciationPercentRow struct { + HouseType string + Day int + DepreciationPercent float64 +} + +type ExpenseDepreciationRepository interface { + GetCandidateFarms(ctx context.Context, period time.Time, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationCandidateRow, error) + GetSnapshotsByPeriodAndFarmIDs(ctx context.Context, period time.Time, farmIDs []uint) ([]entity.FarmDepreciationSnapshot, error) + UpsertSnapshots(ctx context.Context, rows []entity.FarmDepreciationSnapshot) error + DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error + GetLatestTransferInputsByFarms(ctx context.Context, period time.Time, farmIDs []uint) ([]FarmDepreciationLatestTransferRow, error) + GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) + GetLatestManualInputsByFarms(ctx context.Context, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationManualInputRow, error) + UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error + DB() *gorm.DB +} + +type expenseDepreciationRepository struct { + db *gorm.DB +} + +func NewExpenseDepreciationRepository(db *gorm.DB) ExpenseDepreciationRepository { + return &expenseDepreciationRepository{db: db} +} + +func (r *expenseDepreciationRepository) DB() *gorm.DB { + return r.db +} + +func (r *expenseDepreciationRepository) GetCandidateFarms( + ctx context.Context, + period time.Time, + areaIDs, locationIDs, projectFlockIDs []int64, +) ([]FarmDepreciationCandidateRow, error) { + rows := make([]FarmDepreciationCandidateRow, 0) + + query := r.db.WithContext(ctx). + Table("project_flocks AS pf"). + Select("DISTINCT pf.id AS project_flock_id, pf.flock_name AS farm_name"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Where("pf.deleted_at IS NULL"). + Where("pf.category = ?", utils.ProjectFlockCategoryLaying). + Where("(pfk.closed_at IS NULL OR DATE(pfk.closed_at) >= DATE(?))", period) + + if len(areaIDs) > 0 { + query = query.Where("pf.area_id IN ?", areaIDs) + } + if len(locationIDs) > 0 { + query = query.Where("pf.location_id IN ?", locationIDs) + } + if len(projectFlockIDs) > 0 { + query = query.Where("pf.id IN ?", projectFlockIDs) + } + + if err := query.Order("pf.id ASC").Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *expenseDepreciationRepository) GetSnapshotsByPeriodAndFarmIDs( + ctx context.Context, + period time.Time, + farmIDs []uint, +) ([]entity.FarmDepreciationSnapshot, error) { + if len(farmIDs) == 0 { + return []entity.FarmDepreciationSnapshot{}, nil + } + + rows := make([]entity.FarmDepreciationSnapshot, 0) + if err := r.db.WithContext(ctx). + Where("project_flock_id IN ?", farmIDs). + Where("period_date = DATE(?)", period). + Find(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *expenseDepreciationRepository) UpsertSnapshots(ctx context.Context, rows []entity.FarmDepreciationSnapshot) error { + if len(rows) == 0 { + return nil + } + + return r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "project_flock_id"}, + {Name: "period_date"}, + }, + DoUpdates: clause.AssignmentColumns([]string{ + "depreciation_percent_effective", + "depreciation_value", + "pullet_cost_day_n_total", + "components", + "updated_at", + }), + }). + Create(&rows).Error +} + +func (r *expenseDepreciationRepository) DeleteSnapshotsFromDate( + ctx context.Context, + fromDate time.Time, + farmIDs []uint, +) error { + if fromDate.IsZero() { + return nil + } + + query := r.db.WithContext(ctx). + Table("farm_depreciation_snapshots"). + Where("period_date >= DATE(?)", fromDate) + if len(farmIDs) > 0 { + query = query.Where("project_flock_id IN ?", farmIDs) + } + return query.Delete(nil).Error +} + +func (r *expenseDepreciationRepository) GetLatestTransferInputsByFarms( + ctx context.Context, + period time.Time, + farmIDs []uint, +) ([]FarmDepreciationLatestTransferRow, error) { + if len(farmIDs) == 0 { + return []FarmDepreciationLatestTransferRow{}, nil + } + + rows := make([]FarmDepreciationLatestTransferRow, 0) + query := ` +WITH latest_transfer_approval AS ( + SELECT a.approvable_id, a.action + FROM approvals a + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = @approval_type + GROUP BY approvable_id + ) la + ON la.approvable_id = a.approvable_id + AND la.latest_action_at = a.action_at + WHERE a.approvable_type = @approval_type +), +approved_transfers AS ( + SELECT + lt.id, + lt.from_project_flock_id, + lt.to_project_flock_id, + COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) AS effective_date + FROM laying_transfers lt + JOIN latest_transfer_approval lta ON lta.approvable_id = lt.id + WHERE lt.deleted_at IS NULL + AND lt.executed_at IS NOT NULL + AND lta.action = 'APPROVED' +) +SELECT DISTINCT ON (ltt.target_project_flock_kandang_id) + pf.id AS project_flock_id, + pf.flock_name AS farm_name, + pfk.id AS project_flock_kandang_id, + k.id AS kandang_id, + k.name AS kandang_name, + k.house_type::text AS house_type, + at.from_project_flock_id AS source_project_flock_id, + at.effective_date AS transfer_date, + ltt.total_qty AS transfer_qty, + at.id AS transfer_id +FROM laying_transfer_targets ltt +JOIN approved_transfers at ON at.id = ltt.laying_transfer_id +JOIN project_flock_kandangs pfk ON pfk.id = ltt.target_project_flock_kandang_id +JOIN project_flocks pf ON pf.id = pfk.project_flock_id +JOIN kandangs k ON k.id = pfk.kandang_id +WHERE ltt.deleted_at IS NULL + AND pf.id IN @farm_ids + AND at.effective_date <= DATE(@period_date) +ORDER BY ltt.target_project_flock_kandang_id, at.effective_date DESC, at.id DESC +` + + if err := r.db.WithContext(ctx).Raw(query, map[string]any{ + "approval_type": utils.ApprovalWorkflowTransferToLaying.String(), + "farm_ids": farmIDs, + "period_date": period, + }).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *expenseDepreciationRepository) GetDepreciationPercents( + ctx context.Context, + houseTypes []string, + maxDay int, +) (map[string]map[int]float64, error) { + result := make(map[string]map[int]float64) + if len(houseTypes) == 0 || maxDay <= 0 { + return result, nil + } + + rows := make([]houseDepreciationPercentRow, 0) + if err := r.db.WithContext(ctx). + Table("house_depreciation_standards"). + Select("house_type::text AS house_type, day, depreciation_percent"). + Where("house_type::text IN ?", houseTypes). + Where("day <= ?", maxDay). + Order("house_type ASC, day ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if _, exists := result[row.HouseType]; !exists { + result[row.HouseType] = make(map[int]float64) + } + result[row.HouseType][row.Day] = row.DepreciationPercent + } + + return result, nil +} + +func (r *expenseDepreciationRepository) GetLatestManualInputsByFarms( + ctx context.Context, + areaIDs, locationIDs, projectFlockIDs []int64, +) ([]FarmDepreciationManualInputRow, error) { + rows := make([]FarmDepreciationManualInputRow, 0) + + query := r.db.WithContext(ctx). + Table("farm_depreciation_manual_inputs AS fdmi"). + Select(` + fdmi.id AS id, + fdmi.project_flock_id AS project_flock_id, + pf.flock_name AS farm_name, + fdmi.total_cost AS total_cost, + fdmi.note AS note + `). + Joins("JOIN project_flocks AS pf ON pf.id = fdmi.project_flock_id"). + Where("pf.deleted_at IS NULL"). + Where("pf.category = ?", utils.ProjectFlockCategoryLaying) + + if len(areaIDs) > 0 { + query = query.Where("pf.area_id IN ?", areaIDs) + } + if len(locationIDs) > 0 { + query = query.Where("pf.location_id IN ?", locationIDs) + } + if len(projectFlockIDs) > 0 { + query = query.Where("pf.id IN ?", projectFlockIDs) + } + + if err := query. + Order("pf.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *expenseDepreciationRepository) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error { + if row == nil { + return nil + } + + now := time.Now().UTC() + err := r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "project_flock_id"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "total_cost": row.TotalCost, + "note": row.Note, + "updated_at": now, + }), + }). + Create(row).Error + if err != nil { + return err + } + + return r.db.WithContext(ctx). + Table("farm_depreciation_manual_inputs"). + Select("id, project_flock_id, total_cost, note"). + Where("project_flock_id = ?", row.ProjectFlockId). + Take(row).Error +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 2f5eceec..be6da322 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -16,6 +16,9 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Use(m.Auth(u)) route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) + route.Get("/expense/depreciation", ctrl.GetExpenseDepreciation) + route.Get("/expense/depreciation/manual-inputs", ctrl.GetExpenseDepreciationManualInputs) + route.Put("/expense/depreciation/manual-inputs", ctrl.UpsertExpenseDepreciationManualInput) route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing) route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index b866cf96..ce3025db 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -42,6 +42,9 @@ import ( type RepportService interface { GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) + GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) + GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) + UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error) GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) @@ -56,12 +59,14 @@ type repportService struct { Validate *validator.Validate db *gorm.DB ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository + ExpenseDepreciationRepo repportRepo.ExpenseDepreciationRepository MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository PurchaseRepo purchaseRepo.PurchaseRepository ChickinRepo chickinRepo.ProjectChickinRepository RecordingRepo recordingRepo.RecordingRepository ApprovalSvc approvalService.ApprovalService HppSvc approvalService.HppService + HppCostRepo commonRepo.HppCostRepository PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository DebtSupplierRepo repportRepo.DebtSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository @@ -85,12 +90,14 @@ func NewRepportService( db *gorm.DB, validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, + expenseDepreciationRepo repportRepo.ExpenseDepreciationRepository, marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository, purchaseRepo purchaseRepo.PurchaseRepository, chickinRepo chickinRepo.ProjectChickinRepository, recordingRepo recordingRepo.RecordingRepository, approvalSvc approvalService.ApprovalService, hppSvc approvalService.HppService, + hppCostRepo commonRepo.HppCostRepository, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, @@ -105,12 +112,14 @@ func NewRepportService( Validate: validate, db: db, ExpenseRealizationRepo: expenseRealizationRepo, + ExpenseDepreciationRepo: expenseDepreciationRepo, MarketingDeliveryRepo: marketingDeliveryRepo, PurchaseRepo: purchaseRepo, ChickinRepo: chickinRepo, RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, HppSvc: hppSvc, + HppCostRepo: hppCostRepo, PurchaseSupplierRepo: purchaseSupplierRepo, DebtSupplierRepo: debtSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, @@ -164,6 +173,495 @@ func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuer return result, total, nil } +func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) { + params, filters, err := s.parseExpenseDepreciationQuery(ctx) + if err != nil { + return nil, nil, err + } + if err := s.Validate.Struct(params); err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if s.ExpenseDepreciationRepo == nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured") + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD") + } + + candidateRows, err := s.ExpenseDepreciationRepo.GetCandidateFarms( + ctx.Context(), + periodDate, + params.AreaIDs, + params.LocationIDs, + params.ProjectFlockIDs, + ) + if err != nil { + return nil, nil, err + } + + limit := params.Limit + if limit <= 0 { + limit = 10 + } + if len(candidateRows) == 0 { + meta := &dto.ExpenseDepreciationMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: 1, + TotalResults: 0, + Filters: filters, + } + return []dto.ExpenseDepreciationRowDTO{}, meta, nil + } + + farmIDs := make([]uint, 0, len(candidateRows)) + farmNameByID := make(map[uint]string, len(candidateRows)) + for _, row := range candidateRows { + farmIDs = append(farmIDs, row.ProjectFlockID) + farmNameByID[row.ProjectFlockID] = row.FarmName + } + + snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs) + if err != nil { + return nil, nil, err + } + snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots)) + for _, row := range snapshots { + snapshotByFarmID[row.ProjectFlockId] = row + } + + missingFarmIDs := make([]uint, 0) + for _, farmID := range farmIDs { + if _, exists := snapshotByFarmID[farmID]; exists { + continue + } + missingFarmIDs = append(missingFarmIDs, farmID) + } + + if len(missingFarmIDs) > 0 { + computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID) + if computeErr != nil { + return nil, nil, computeErr + } + if len(computedSnapshots) > 0 { + if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil { + return nil, nil, err + } + for _, row := range computedSnapshots { + snapshotByFarmID[row.ProjectFlockId] = row + } + } + } + + rows := make([]dto.ExpenseDepreciationRowDTO, 0, len(candidateRows)) + for _, candidate := range candidateRows { + snapshot, exists := snapshotByFarmID[candidate.ProjectFlockID] + if !exists { + rows = append(rows, dto.ExpenseDepreciationRowDTO{ + ProjectFlockID: int64(candidate.ProjectFlockID), + FarmName: candidate.FarmName, + Period: params.Period, + DepreciationPercentEffective: 0, + DepreciationValue: 0, + PulletCostDayNTotal: 0, + Components: map[string]any{}, + }) + continue + } + rows = append(rows, dto.ExpenseDepreciationRowDTO{ + ProjectFlockID: int64(snapshot.ProjectFlockId), + FarmName: candidate.FarmName, + Period: params.Period, + DepreciationPercentEffective: snapshot.DepreciationPercentEffective, + DepreciationValue: snapshot.DepreciationValue, + PulletCostDayNTotal: snapshot.PulletCostDayNTotal, + Components: parseSnapshotComponents(snapshot.Components), + }) + } + + totalResults := int64(len(rows)) + totalPages := int64(0) + if totalResults > 0 { + totalPages = int64(math.Ceil(float64(totalResults) / float64(limit))) + } + if totalPages == 0 { + totalPages = 1 + } + + offset := (params.Page - 1) * limit + if offset < 0 { + offset = 0 + } + if offset > len(rows) { + offset = len(rows) + } + end := offset + limit + if end > len(rows) { + end = len(rows) + } + + meta := &dto.ExpenseDepreciationMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: totalPages, + TotalResults: totalResults, + Filters: filters, + } + + return rows[offset:end], meta, nil +} + +func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) { + params, filters, err := s.parseExpenseDepreciationQuery(ctx) + if err != nil { + return nil, nil, err + } + if s.ExpenseDepreciationRepo == nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured") + } + + repoRows, err := s.ExpenseDepreciationRepo.GetLatestManualInputsByFarms( + ctx.Context(), + params.AreaIDs, + params.LocationIDs, + params.ProjectFlockIDs, + ) + if err != nil { + return nil, nil, err + } + + rows := make([]dto.ExpenseDepreciationManualInputRowDTO, 0, len(repoRows)) + for _, row := range repoRows { + rows = append(rows, dto.ExpenseDepreciationManualInputRowDTO{ + ID: int64(row.Id), + ProjectFlockID: int64(row.ProjectFlockID), + FarmName: row.FarmName, + TotalCost: row.TotalCost, + Note: row.Note, + }) + } + + limit := params.Limit + if limit <= 0 { + limit = 10 + } + totalResults := int64(len(rows)) + totalPages := int64(0) + if totalResults > 0 { + totalPages = int64(math.Ceil(float64(totalResults) / float64(limit))) + } + if totalPages == 0 { + totalPages = 1 + } + + offset := (params.Page - 1) * limit + if offset < 0 { + offset = 0 + } + if offset > len(rows) { + offset = len(rows) + } + end := offset + limit + if end > len(rows) { + end = len(rows) + } + + meta := &dto.ExpenseDepreciationMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: totalPages, + TotalResults: totalResults, + Filters: filters, + } + + return rows[offset:end], meta, nil +} + +func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error) { + if req == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "request is required") + } + if err := s.Validate.Struct(req); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if s.ExpenseDepreciationRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured") + } + + row := entity.FarmDepreciationManualInput{ + ProjectFlockId: req.ProjectFlockID, + TotalCost: req.TotalCost, + Note: req.Note, + } + if err := s.ExpenseDepreciationRepo.UpsertManualInput(ctx.Context(), &row); err != nil { + return nil, err + } + + response := &dto.ExpenseDepreciationManualInputRowDTO{ + ID: int64(row.Id), + ProjectFlockID: int64(row.ProjectFlockId), + TotalCost: row.TotalCost, + Note: row.Note, + } + + listRows, listErr := s.ExpenseDepreciationRepo.GetLatestManualInputsByFarms( + ctx.Context(), + nil, + nil, + []int64{int64(row.ProjectFlockId)}, + ) + if listErr == nil { + for _, listRow := range listRows { + if listRow.ProjectFlockID == row.ProjectFlockId { + response.FarmName = listRow.FarmName + break + } + } + } + + return response, nil +} + +type depreciationKandangComponent struct { + ProjectFlockKandangID uint `json:"project_flock_kandang_id"` + KandangID uint `json:"kandang_id"` + KandangName string `json:"kandang_name"` + TransferID uint `json:"transfer_id"` + TransferDate string `json:"transfer_date"` + SourceProjectFlockID uint `json:"source_project_flock_id"` + HouseType string `json:"house_type"` + DayN int `json:"day_n"` + DepreciationPercent float64 `json:"depreciation_percent"` + TransferQty float64 `json:"transfer_qty"` + PulletCostDayN float64 `json:"pullet_cost_day_n"` + DepreciationValue float64 `json:"depreciation_value"` +} + +type depreciationFarmComponents struct { + KandangCount int `json:"kandang_count"` + Kandang []depreciationKandangComponent `json:"kandang"` +} + +func (s *repportService) computeExpenseDepreciationSnapshots( + ctx context.Context, + periodDate time.Time, + farmIDs []uint, + farmNameByID map[uint]string, +) ([]entity.FarmDepreciationSnapshot, error) { + if len(farmIDs) == 0 { + return []entity.FarmDepreciationSnapshot{}, nil + } + + inputRows, err := s.ExpenseDepreciationRepo.GetLatestTransferInputsByFarms(ctx, periodDate, farmIDs) + if err != nil { + return nil, err + } + + groupedByFarm := make(map[uint][]repportRepo.FarmDepreciationLatestTransferRow, len(farmIDs)) + houseTypeSet := make(map[string]struct{}) + maxDay := 0 + + for _, row := range inputRows { + groupedByFarm[row.ProjectFlockID] = append(groupedByFarm[row.ProjectFlockID], row) + dayN := depreciationDayNumber(row.TransferDate, periodDate) + if dayN > maxDay { + maxDay = dayN + } + houseType := strings.TrimSpace(strings.ToLower(valueOrEmptyString(row.HouseType))) + if houseType != "" { + houseTypeSet[houseType] = struct{}{} + } + } + + houseTypes := make([]string, 0, len(houseTypeSet)) + for houseType := range houseTypeSet { + houseTypes = append(houseTypes, houseType) + } + sort.Strings(houseTypes) + + percentByHouseType, err := s.ExpenseDepreciationRepo.GetDepreciationPercents(ctx, houseTypes, maxDay) + if err != nil { + return nil, err + } + + type sourceCostCacheItem struct { + totalDepCost float64 + } + sourceCostCache := make(map[string]sourceCostCacheItem) + sourcePopulationCache := make(map[uint]float64) + + result := make([]entity.FarmDepreciationSnapshot, 0, len(farmIDs)) + for _, farmID := range farmIDs { + farmRows := groupedByFarm[farmID] + components := depreciationFarmComponents{ + KandangCount: len(farmRows), + Kandang: make([]depreciationKandangComponent, 0, len(farmRows)), + } + + totalDepreciationValue := 0.0 + totalPulletCostDayN := 0.0 + for _, row := range farmRows { + dayN := depreciationDayNumber(row.TransferDate, periodDate) + houseType := strings.TrimSpace(strings.ToLower(valueOrEmptyString(row.HouseType))) + + transferDateKey := row.TransferDate.Format("2006-01-02") + cacheKey := fmt.Sprintf("%d|%s", row.SourceProjectFlockID, transferDateKey) + cached, exists := sourceCostCache[cacheKey] + if !exists { + endOfDay := row.TransferDate.Add(24 * time.Hour) + sourceDepCost, calcErr := s.HppSvc.GetTotalDepresiasiFlockGrowing(row.SourceProjectFlockID, &endOfDay) + if calcErr != nil { + return nil, calcErr + } + cached = sourceCostCacheItem{totalDepCost: sourceDepCost} + sourceCostCache[cacheKey] = cached + } + + sourcePopulation, popExists := sourcePopulationCache[row.SourceProjectFlockID] + if !popExists { + if s.HppCostRepo == nil { + sourcePopulation = 0 + } else { + kandangIDs, idsErr := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, row.SourceProjectFlockID) + if idsErr != nil { + return nil, idsErr + } + population, popErr := s.HppCostRepo.GetTotalPopulation(ctx, kandangIDs) + if popErr != nil { + return nil, popErr + } + sourcePopulation = population + } + sourcePopulationCache[row.SourceProjectFlockID] = sourcePopulation + } + + initialPulletCost := 0.0 + if sourcePopulation > 0 { + initialPulletCost = (cached.totalDepCost * row.TransferQty) / sourcePopulation + } + + pulletCostDayN, depreciationValue, depreciationPercent := calculateDepreciationAtDayN( + initialPulletCost, + dayN, + houseType, + percentByHouseType, + ) + + totalPulletCostDayN += pulletCostDayN + totalDepreciationValue += depreciationValue + + components.Kandang = append(components.Kandang, depreciationKandangComponent{ + ProjectFlockKandangID: row.ProjectFlockKandangID, + KandangID: row.KandangID, + KandangName: row.KandangName, + TransferID: row.TransferID, + TransferDate: row.TransferDate.Format("2006-01-02"), + SourceProjectFlockID: row.SourceProjectFlockID, + HouseType: houseType, + DayN: dayN, + DepreciationPercent: depreciationPercent, + TransferQty: row.TransferQty, + PulletCostDayN: pulletCostDayN, + DepreciationValue: depreciationValue, + }) + } + + effectivePercent := 0.0 + effectivePercent = calculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN) + + componentsJSON, marshalErr := json.Marshal(components) + if marshalErr != nil { + return nil, marshalErr + } + + result = append(result, entity.FarmDepreciationSnapshot{ + ProjectFlockId: farmID, + PeriodDate: periodDate, + DepreciationPercentEffective: effectivePercent, + DepreciationValue: totalDepreciationValue, + PulletCostDayNTotal: totalPulletCostDayN, + Components: componentsJSON, + }) + } + + return result, nil +} + +func depreciationDayNumber(transferDate time.Time, periodDate time.Time) int { + transfer := time.Date(transferDate.Year(), transferDate.Month(), transferDate.Day(), 0, 0, 0, 0, transferDate.Location()) + period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location()) + if period.Before(transfer) { + return 0 + } + return int(period.Sub(transfer).Hours()/24) + 1 +} + +func calculateDepreciationAtDayN( + initialPulletCost float64, + dayN int, + houseType string, + percentByHouseType map[string]map[int]float64, +) (float64, float64, float64) { + if initialPulletCost <= 0 || dayN <= 0 || houseType == "" { + return 0, 0, 0 + } + + housePercent, exists := percentByHouseType[houseType] + if !exists { + return 0, 0, 0 + } + + current := initialPulletCost + pulletCostDayN := 0.0 + depreciationValue := 0.0 + depreciationPercent := 0.0 + for day := 1; day <= dayN; day++ { + pct := housePercent[day] + dep := current * (pct / 100) + if day == dayN { + pulletCostDayN = current + depreciationValue = dep + depreciationPercent = pct + } + current -= dep + if current < 0 { + current = 0 + } + } + return pulletCostDayN, depreciationValue, depreciationPercent +} + +func calculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 { + if totalPulletCostDayN <= 0 { + return 0 + } + return (totalDepreciationValue / totalPulletCostDayN) * 100 +} + +func parseSnapshotComponents(raw []byte) any { + if len(raw) == 0 { + return map[string]any{} + } + var out any + if err := json.Unmarshal(raw, &out); err != nil { + return map[string]any{} + } + return out +} + +func valueOrEmptyString(v *string) string { + if v == nil { + return "" + } + return *v +} + func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -2133,6 +2631,84 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return params, filters, nil } +func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validation.ExpenseDepreciationQuery, dto.ExpenseDepreciationFiltersDTO, error) { + page := ctx.QueryInt("page", 1) + if page < 1 { + page = 1 + } + limit := ctx.QueryInt("limit", 10) + if limit < 1 { + limit = 10 + } + + rawArea := ctx.Query("area_id", "") + rawLocation := ctx.Query("location_id", "") + rawProjectFlock := ctx.Query("project_flock_id", "") + period := strings.TrimSpace(ctx.Query("period", "")) + + areaIDs, err := parseCommaSeparatedInt64s(rawArea) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + locationIDs, err := parseCommaSeparatedInt64s(rawLocation) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + projectFlockIDs, err := parseCommaSeparatedInt64s(rawProjectFlock) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, err + } + areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.ExpenseDepreciationFiltersDTO{}, err + } + + if locationScope.Restrict { + allowed := toInt64Slice(locationScope.IDs) + if len(allowed) == 0 { + locationIDs = []int64{-1} + } else if len(locationIDs) > 0 { + locationIDs = intersectInt64(locationIDs, allowed) + } else { + locationIDs = allowed + } + } + + if areaScope.Restrict { + allowed := toInt64Slice(areaScope.IDs) + if len(allowed) == 0 { + areaIDs = []int64{-1} + } else if len(areaIDs) > 0 { + areaIDs = intersectInt64(areaIDs, allowed) + } else { + areaIDs = allowed + } + } + + params := &validation.ExpenseDepreciationQuery{ + Page: page, + Limit: limit, + Period: period, + ProjectFlockIDs: projectFlockIDs, + AreaIDs: areaIDs, + LocationIDs: locationIDs, + } + + filters := dto.NewExpenseDepreciationFiltersDTO( + rawArea, + rawLocation, + rawProjectFlock, + period, + ) + + return params, filters, nil +} + func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index d248c779..7130346c 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -75,6 +75,21 @@ type HppPerKandangQuery struct { WeightMax *float64 `query:"-"` } +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"` + Period string `query:"period" validate:"required,datetime=2006-01-02"` + ProjectFlockIDs []int64 `query:"-"` + AreaIDs []int64 `query:"-"` + LocationIDs []int64 `query:"-"` +} + +type ExpenseDepreciationManualInputUpsert struct { + ProjectFlockID uint `json:"project_flock_id" validate:"required,gt=0"` + TotalCost float64 `json:"total_cost" validate:"required,gte=0"` + Note *string `json:"note" validate:"omitempty,max=1000"` +} + type ProductionResultQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` diff --git a/internal/readapi/readapi.go b/internal/readapi/readapi.go index 2ae62472..59fe1329 100644 --- a/internal/readapi/readapi.go +++ b/internal/readapi/readapi.go @@ -983,6 +983,23 @@ func describeRoute(route normalizedRoute) routeMeta { {Name: "area_id", In: "query", Description: "Area id filter.", Example: 1}, {Name: "realization_date", In: "query", Description: "Realization date filter (YYYY-MM-DD).", Example: "2026-01-15"}, } + case "/api/reports/expense/depreciation": + meta.QueryParams = []parameterMeta{ + {Name: "page", In: "query", Description: "Page number.", Example: 1}, + {Name: "limit", In: "query", Description: "Page size.", Example: 10}, + {Name: "period", In: "query", Description: "Daily period filter (YYYY-MM-DD).", Required: true, Example: "2026-01-01"}, + {Name: "project_flock_id", In: "query", Description: "Comma separated project flock ids.", Example: "1,2"}, + {Name: "area_id", In: "query", Description: "Comma separated area ids.", Example: "1,2"}, + {Name: "location_id", In: "query", Description: "Comma separated location ids.", Example: "1,2"}, + } + case "/api/reports/expense/depreciation/manual-inputs": + meta.QueryParams = []parameterMeta{ + {Name: "page", In: "query", Description: "Page number.", Example: 1}, + {Name: "limit", In: "query", Description: "Page size.", Example: 10}, + {Name: "project_flock_id", In: "query", Description: "Comma separated project flock ids.", Example: "1,2"}, + {Name: "area_id", In: "query", Description: "Comma separated area ids.", Example: "1,2"}, + {Name: "location_id", In: "query", Description: "Comma separated location ids.", Example: "1,2"}, + } case "/api/reports/marketing": meta.QueryParams = []parameterMeta{ {Name: "page", In: "query", Description: "Page number.", Example: 1}, diff --git a/internal/utils/constant.go b/internal/utils/constant.go index dfe4ef6e..eaed4a97 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -581,6 +581,17 @@ const ( KandangStatusActive KandangStatus = "ACTIVE" ) +// ------------------------------------------------------------------- +// House Type +// ------------------------------------------------------------------- + +type HouseType string + +const ( + HouseTypeOpenHouse HouseType = "open_house" + HouseTypeCloseHouse HouseType = "close_house" +) + // ------------------------------------------------------------------- // Marketing Type // ------------------------------------------------------------------- diff --git a/scripts/sql/seed_house_depreciation_standards.sql b/scripts/sql/seed_house_depreciation_standards.sql new file mode 100644 index 00000000..7e14ae6e --- /dev/null +++ b/scripts/sql/seed_house_depreciation_standards.sql @@ -0,0 +1,1095 @@ +-- Generated from /Users/macbookairm1/Downloads/Req IT.xlsx (sheet: % Depresiasi) +-- close_house: days 1-553, open_house: days 1-532 +-- depreciation_percent is stored as percent number (e.g. 0.154943 means 0.154943%) +INSERT INTO house_depreciation_standards (name, effective_date, house_type, day, depreciation_percent) +VALUES + ('depresiasi 2026', NOW()::date, 'close_house', 1, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 2, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 3, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 4, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 5, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 6, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 7, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 8, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 9, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 10, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 11, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 12, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 13, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 14, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 15, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 16, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 17, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 18, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 19, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 20, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 21, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 22, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 23, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 24, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 25, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 26, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 27, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 28, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 29, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 30, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 31, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 32, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 33, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 34, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 35, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 36, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 37, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 38, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 39, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 40, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 41, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 42, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 43, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 44, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 45, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 46, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 47, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 48, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 49, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 50, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 51, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 52, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 53, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 54, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 55, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 56, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 57, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 58, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 59, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 60, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 61, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 62, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 63, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 64, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 65, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 66, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 67, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 68, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 69, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 70, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 71, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 72, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 73, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 74, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 75, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 76, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 77, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 78, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 79, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 80, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 81, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 82, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 83, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 84, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 85, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 86, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 87, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 88, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 89, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 90, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 91, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 92, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 93, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 94, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 95, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 96, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 97, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 98, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 99, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 100, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 101, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 102, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 103, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 104, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 105, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 106, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 107, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 108, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 109, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 110, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 111, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 112, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 113, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 114, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 115, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 116, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 117, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 118, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 119, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 120, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 121, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 122, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 123, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 124, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 125, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 126, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 127, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 128, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 129, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 130, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 131, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 132, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 133, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 134, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 135, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 136, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 137, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 138, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 139, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 140, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 141, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 142, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 143, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 144, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 145, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 146, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 147, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 148, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 149, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 150, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 151, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 152, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 153, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 154, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 155, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 156, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 157, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 158, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 159, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 160, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 161, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 162, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 163, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 164, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 165, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 166, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 167, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 168, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 169, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 170, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 171, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 172, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 173, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 174, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 175, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 176, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 177, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 178, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 179, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 180, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 181, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 182, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 183, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 184, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 185, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 186, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 187, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 188, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 189, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 190, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 191, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 192, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 193, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 194, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 195, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 196, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 197, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 198, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 199, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 200, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 201, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 202, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 203, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 204, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 205, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 206, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 207, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 208, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 209, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 210, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 211, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 212, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 213, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 214, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 215, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 216, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 217, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 218, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 219, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 220, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 221, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 222, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 223, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 224, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 225, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 226, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 227, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 228, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 229, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 230, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 231, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 232, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 233, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 234, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 235, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 236, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 237, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 238, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 239, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 240, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 241, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 242, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 243, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 244, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 245, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 246, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 247, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 248, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 249, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 250, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 251, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 252, 0.216920), + ('depresiasi 2026', NOW()::date, 'close_house', 253, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 254, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 255, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 256, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 257, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 258, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 259, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 260, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 261, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 262, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 263, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 264, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 265, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 266, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 267, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 268, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 269, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 270, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 271, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 272, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 273, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 274, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 275, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 276, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 277, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 278, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 279, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 280, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 281, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 282, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 283, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 284, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 285, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 286, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 287, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 288, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 289, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 290, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 291, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 292, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 293, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 294, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 295, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 296, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 297, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 298, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 299, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 300, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 301, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 302, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 303, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 304, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 305, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 306, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 307, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 308, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 309, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 310, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 311, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 312, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 313, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 314, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 315, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 316, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 317, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 318, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 319, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 320, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 321, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 322, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 323, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 324, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 325, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 326, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 327, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 328, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 329, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 330, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 331, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 332, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 333, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 334, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 335, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 336, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 337, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 338, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 339, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 340, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 341, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 342, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 343, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 344, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 345, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 346, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 347, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 348, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 349, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 350, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 351, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 352, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 353, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 354, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 355, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 356, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 357, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 358, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 359, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 360, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 361, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 362, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 363, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 364, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 365, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 366, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 367, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 368, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 369, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 370, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 371, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 372, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 373, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 374, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 375, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 376, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 377, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 378, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 379, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 380, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 381, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 382, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 383, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 384, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 385, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 386, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 387, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 388, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 389, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 390, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 391, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 392, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 393, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 394, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 395, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 396, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 397, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 398, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 399, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 400, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 401, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 402, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 403, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 404, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 405, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 406, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 407, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 408, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 409, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 410, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 411, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 412, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 413, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 414, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 415, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 416, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 417, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 418, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 419, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 420, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 421, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 422, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 423, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 424, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 425, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 426, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 427, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 428, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 429, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 430, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 431, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 432, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 433, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 434, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 435, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 436, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 437, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 438, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 439, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 440, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 441, 0.185931), + ('depresiasi 2026', NOW()::date, 'close_house', 442, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 443, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 444, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 445, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 446, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 447, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 448, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 449, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 450, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 451, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 452, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 453, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 454, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 455, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 456, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 457, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 458, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 459, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 460, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 461, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 462, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 463, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 464, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 465, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 466, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 467, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 468, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 469, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 470, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 471, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 472, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 473, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 474, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 475, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 476, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 477, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 478, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 479, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 480, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 481, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 482, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 483, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 484, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 485, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 486, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 487, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 488, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 489, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 490, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 491, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 492, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 493, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 494, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 495, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 496, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 497, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 498, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 499, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 500, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 501, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 502, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 503, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 504, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 505, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 506, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 507, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 508, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 509, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 510, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 511, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 512, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 513, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 514, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 515, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 516, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 517, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 518, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 519, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 520, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 521, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 522, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 523, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 524, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 525, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 526, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 527, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 528, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 529, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 530, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 531, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 532, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 533, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 534, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 535, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 536, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 537, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 538, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 539, 0.154943), + ('depresiasi 2026', NOW()::date, 'close_house', 540, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 541, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 542, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 543, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 544, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 545, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 546, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 547, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 548, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 549, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 550, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 551, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 552, 0.123954), + ('depresiasi 2026', NOW()::date, 'close_house', 553, 0.123954), + ('depresiasi 2026', NOW()::date, 'open_house', 1, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 2, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 3, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 4, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 5, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 6, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 7, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 8, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 9, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 10, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 11, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 12, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 13, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 14, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 15, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 16, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 17, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 18, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 19, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 20, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 21, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 22, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 23, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 24, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 25, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 26, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 27, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 28, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 29, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 30, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 31, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 32, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 33, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 34, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 35, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 36, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 37, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 38, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 39, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 40, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 41, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 42, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 43, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 44, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 45, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 46, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 47, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 48, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 49, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 50, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 51, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 52, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 53, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 54, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 55, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 56, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 57, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 58, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 59, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 60, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 61, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 62, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 63, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 64, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 65, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 66, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 67, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 68, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 69, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 70, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 71, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 72, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 73, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 74, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 75, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 76, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 77, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 78, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 79, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 80, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 81, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 82, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 83, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 84, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 85, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 86, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 87, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 88, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 89, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 90, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 91, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 92, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 93, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 94, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 95, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 96, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 97, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 98, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 99, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 100, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 101, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 102, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 103, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 104, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 105, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 106, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 107, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 108, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 109, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 110, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 111, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 112, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 113, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 114, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 115, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 116, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 117, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 118, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 119, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 120, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 121, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 122, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 123, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 124, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 125, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 126, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 127, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 128, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 129, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 130, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 131, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 132, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 133, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 134, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 135, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 136, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 137, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 138, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 139, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 140, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 141, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 142, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 143, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 144, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 145, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 146, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 147, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 148, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 149, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 150, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 151, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 152, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 153, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 154, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 155, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 156, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 157, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 158, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 159, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 160, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 161, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 162, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 163, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 164, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 165, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 166, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 167, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 168, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 169, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 170, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 171, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 172, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 173, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 174, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 175, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 176, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 177, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 178, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 179, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 180, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 181, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 182, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 183, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 184, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 185, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 186, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 187, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 188, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 189, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 190, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 191, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 192, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 193, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 194, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 195, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 196, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 197, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 198, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 199, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 200, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 201, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 202, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 203, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 204, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 205, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 206, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 207, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 208, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 209, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 210, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 211, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 212, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 213, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 214, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 215, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 216, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 217, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 218, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 219, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 220, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 221, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 222, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 223, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 224, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 225, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 226, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 227, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 228, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 229, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 230, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 231, 0.225734), + ('depresiasi 2026', NOW()::date, 'open_house', 232, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 233, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 234, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 235, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 236, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 237, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 238, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 239, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 240, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 241, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 242, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 243, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 244, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 245, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 246, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 247, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 248, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 249, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 250, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 251, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 252, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 253, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 254, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 255, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 256, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 257, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 258, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 259, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 260, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 261, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 262, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 263, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 264, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 265, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 266, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 267, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 268, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 269, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 270, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 271, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 272, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 273, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 274, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 275, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 276, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 277, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 278, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 279, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 280, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 281, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 282, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 283, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 284, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 285, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 286, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 287, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 288, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 289, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 290, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 291, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 292, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 293, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 294, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 295, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 296, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 297, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 298, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 299, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 300, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 301, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 302, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 303, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 304, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 305, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 306, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 307, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 308, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 309, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 310, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 311, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 312, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 313, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 314, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 315, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 316, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 317, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 318, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 319, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 320, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 321, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 322, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 323, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 324, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 325, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 326, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 327, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 328, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 329, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 330, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 331, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 332, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 333, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 334, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 335, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 336, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 337, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 338, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 339, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 340, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 341, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 342, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 343, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 344, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 345, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 346, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 347, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 348, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 349, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 350, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 351, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 352, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 353, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 354, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 355, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 356, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 357, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 358, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 359, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 360, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 361, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 362, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 363, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 364, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 365, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 366, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 367, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 368, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 369, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 370, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 371, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 372, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 373, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 374, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 375, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 376, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 377, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 378, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 379, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 380, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 381, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 382, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 383, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 384, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 385, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 386, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 387, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 388, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 389, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 390, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 391, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 392, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 393, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 394, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 395, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 396, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 397, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 398, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 399, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 400, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 401, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 402, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 403, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 404, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 405, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 406, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 407, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 408, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 409, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 410, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 411, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 412, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 413, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 414, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 415, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 416, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 417, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 418, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 419, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 420, 0.193486), + ('depresiasi 2026', NOW()::date, 'open_house', 421, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 422, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 423, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 424, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 425, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 426, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 427, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 428, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 429, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 430, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 431, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 432, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 433, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 434, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 435, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 436, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 437, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 438, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 439, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 440, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 441, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 442, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 443, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 444, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 445, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 446, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 447, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 448, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 449, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 450, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 451, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 452, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 453, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 454, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 455, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 456, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 457, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 458, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 459, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 460, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 461, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 462, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 463, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 464, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 465, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 466, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 467, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 468, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 469, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 470, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 471, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 472, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 473, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 474, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 475, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 476, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 477, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 478, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 479, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 480, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 481, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 482, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 483, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 484, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 485, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 486, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 487, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 488, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 489, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 490, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 491, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 492, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 493, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 494, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 495, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 496, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 497, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 498, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 499, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 500, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 501, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 502, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 503, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 504, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 505, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 506, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 507, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 508, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 509, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 510, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 511, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 512, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 513, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 514, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 515, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 516, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 517, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 518, 0.161238), + ('depresiasi 2026', NOW()::date, 'open_house', 519, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 520, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 521, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 522, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 523, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 524, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 525, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 526, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 527, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 528, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 529, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 530, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 531, 0.128991), + ('depresiasi 2026', NOW()::date, 'open_house', 532, 0.128991) +ON CONFLICT (house_type, day) DO UPDATE +SET name = EXCLUDED.name, + effective_date = EXCLUDED.effective_date, + depreciation_percent = EXCLUDED.depreciation_percent, + updated_at = NOW(); From 187e497f9798deb8b3c21e55b8b3bea098f24b82 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 18 Apr 2026 20:08:42 +0700 Subject: [PATCH 2/8] add common service hpp v2; adjust query marketing without stock allocation --- .../repository/common.hppv2.repository.go | 431 ++++++++++++++++++ .../common/service/common.hppv2.service.go | 163 +++++++ internal/modules/repports/module.go | 3 + .../repports/services/repport.service.go | 7 +- 4 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 internal/common/repository/common.hppv2.repository.go create mode 100644 internal/common/service/common.hppv2.service.go diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go new file mode 100644 index 00000000..74195f95 --- /dev/null +++ b/internal/common/repository/common.hppv2.repository.go @@ -0,0 +1,431 @@ +package repository + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type HppV2CostRepository interface { + GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) + GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) + GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) + GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) + GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error) + GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) +} + +type HppV2RepositoryImpl struct { + db *gorm.DB +} + +func NewHppV2CostRepository(db *gorm.DB) HppV2CostRepository { + return &HppV2RepositoryImpl{db: db} +} + +func (r *HppV2RepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) { + var ids []uint + err := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Select("id"). + Where("project_flock_id = ?", projectFlockId). + Scan(&ids).Error + if err != nil { + return nil, err + } + + return ids, nil +} + +func (r *HppV2RepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableRecordingStock := fifo.UsableKeyRecordingStock.String() + + var total float64 + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, + stockableAdjustment, + ). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableRecordingStock, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date). + Where("f.name = ?", utils.FlagPakan). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppV2RepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select("COALESCE(SUM(pc.usage_qty), 0)"). + Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppV2RepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + var totals struct { + TotalPieces float64 + TotalWeightKg float64 + } + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg"). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date). + Scan(&totals).Error + if err != nil { + return 0, 0, err + } + + var adjustmentTotalWeight float64 + adjustmentSubQuery := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT ast.id AS adjustment_id, ast.price AS price"). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = std.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyStockTransferOut.String(), + fifo.StockableKeyAdjustmentIn.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND ast.product_warehouse_id = std.source_product_warehouse_id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date) + + err = r.db.WithContext(ctx). + Table("(?) AS adjustment_sources", adjustmentSubQuery). + Select("COALESCE(SUM(adjustment_sources.price), 0)"). + Scan(&adjustmentTotalWeight).Error + if err != nil { + return 0, 0, err + } + + totals.TotalWeightKg += adjustmentTotalWeight + + return totals.TotalPieces, totals.TotalWeightKg, nil +} + +func (r *HppV2RepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds( + ctx context.Context, + projectFlockKandangIDs []uint, + startDate *time.Time, + endDate *time.Time, +) (float64, float64, error) { + if len(projectFlockKandangIDs) == 0 { + return 0, 0, nil + } + if endDate == nil { + now := time.Now() + endDate = &now + } + if startDate == nil { + startDate = endDate + } + + eggFlags := []string{ + string(utils.FlagTelur), + string(utils.FlagTelurUtuh), + string(utils.FlagTelurPecah), + string(utils.FlagTelurPutih), + string(utils.FlagTelurRetak), + string(utils.FlagTelurPapacal), + string(utils.FlagTelurJumbo), + } + + query := ` +WITH selected_pfk AS ( + SELECT pfk.id, k.location_id + FROM project_flock_kandangs pfk + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE pfk.id IN ? +), +selected_locations AS ( + SELECT DISTINCT location_id + FROM selected_pfk +), +sales_kandang AS ( + SELECT DISTINCT + mdp.id AS mdp_id, + COALESCE(mdp.usage_qty, 0) AS usage_qty, + COALESCE(mdp.total_weight, 0) AS total_weight + FROM marketing_delivery_products mdp + JOIN marketing_products mp ON mp.id = mdp.marketing_product_id + JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id + JOIN warehouses w ON w.id = pw.warehouse_id + WHERE mdp.delivery_date IS NOT NULL + AND mdp.delivery_date <= ? + AND UPPER(COALESCE(w.type, '')) = 'KANDANG' + AND pw.project_flock_kandang_id IN (SELECT id FROM selected_pfk) + AND EXISTS ( + SELECT 1 + FROM recording_eggs re + JOIN recordings rr ON rr.id = re.recording_id + WHERE re.product_warehouse_id = mp.product_warehouse_id + AND COALESCE(re.project_flock_kandang_id, rr.project_flock_kandangs_id) IN (SELECT id FROM selected_pfk) + AND rr.deleted_at IS NULL + AND DATE(rr.record_datetime) <= DATE(mdp.delivery_date) + ) + AND EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = pw.product_id + AND f.name IN ? + ) +), +sales_lokasi AS ( + SELECT DISTINCT + mdp.id AS mdp_id, + COALESCE(mdp.usage_qty, 0) AS usage_qty, + COALESCE(mdp.total_weight, 0) AS total_weight, + mdp.delivery_date AS delivery_date, + pw.id AS lokasi_pw_id, + pw.product_id AS product_id, + w.location_id AS location_id + FROM marketing_delivery_products mdp + JOIN marketing_products mp ON mp.id = mdp.marketing_product_id + JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id + JOIN warehouses w ON w.id = pw.warehouse_id + WHERE mdp.delivery_date IS NOT NULL + AND mdp.delivery_date <= ? + AND UPPER(COALESCE(w.type, '')) = 'LOKASI' + AND w.location_id IN (SELECT location_id FROM selected_locations) + AND EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = pw.product_id + AND f.name IN ? + ) +), +transfer_pairs AS ( + SELECT + std.source_product_warehouse_id AS source_pw_id, + std.dest_product_warehouse_id AS dest_pw_id, + MIN(st.transfer_date) AS first_transfer_date + FROM stock_transfer_details std + JOIN stock_transfers st ON st.id = std.stock_transfer_id + WHERE std.source_product_warehouse_id IS NOT NULL + AND std.dest_product_warehouse_id IS NOT NULL + GROUP BY std.source_product_warehouse_id, std.dest_product_warehouse_id +), +adj_pool AS ( + SELECT + sl.mdp_id, + SUM(CASE + WHEN spw.project_flock_kandang_id IN (SELECT id FROM selected_pfk) + THEN COALESCE(ast.usage_qty, 0) + ELSE 0 + END) AS sel_usage_qty, + SUM(COALESCE(ast.usage_qty, 0)) AS farm_usage_qty, + SUM(CASE + WHEN spw.project_flock_kandang_id IN (SELECT id FROM selected_pfk) + THEN COALESCE(ast.price, 0) + ELSE 0 + END) AS sel_price_sum, + SUM(COALESCE(ast.price, 0)) AS farm_price_sum + FROM sales_lokasi sl + JOIN transfer_pairs tf + ON tf.dest_pw_id = sl.lokasi_pw_id + AND DATE(tf.first_transfer_date) <= DATE(sl.delivery_date) + JOIN product_warehouses spw + ON spw.id = tf.source_pw_id + AND spw.product_id = sl.product_id + JOIN warehouses sw ON sw.id = spw.warehouse_id + JOIN adjustment_stocks ast ON ast.product_warehouse_id = tf.source_pw_id + WHERE UPPER(COALESCE(sw.type, '')) = 'KANDANG' + AND sw.location_id = sl.location_id + AND UPPER(COALESCE(ast.function_code, '')) = UPPER(?) + AND UPPER(COALESCE(ast.transaction_type, '')) = UPPER(?) + AND DATE(ast.created_at) <= DATE(sl.delivery_date) + GROUP BY sl.mdp_id +), +sales_lokasi_adj AS ( + SELECT sl.* + FROM sales_lokasi sl + JOIN adj_pool ap ON ap.mdp_id = sl.mdp_id + WHERE COALESCE(ap.farm_usage_qty, 0) > 0 + OR COALESCE(ap.farm_price_sum, 0) > 0 +), +sales_lokasi_rec AS ( + SELECT sl.* + FROM sales_lokasi sl + WHERE NOT EXISTS ( + SELECT 1 FROM sales_lokasi_adj sla WHERE sla.mdp_id = sl.mdp_id + ) +), +rec_pool AS ( + SELECT + sl.mdp_id, + SUM(CASE + WHEN COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) IN (SELECT id FROM selected_pfk) + THEN COALESCE(re.qty, 0) + ELSE 0 + END) AS sel_qty, + SUM(COALESCE(re.qty, 0)) AS farm_qty, + SUM(CASE + WHEN COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) IN (SELECT id FROM selected_pfk) + THEN COALESCE(re.weight, 0) + ELSE 0 + END) AS sel_weight, + SUM(COALESCE(re.weight, 0)) AS farm_weight + FROM sales_lokasi_rec sl + JOIN recordings r + ON r.deleted_at IS NULL + AND DATE(r.record_datetime) <= DATE(sl.delivery_date) + JOIN recording_eggs re + ON re.recording_id = r.id + AND re.product_warehouse_id = sl.lokasi_pw_id + JOIN project_flock_kandangs pfk + ON pfk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) + JOIN kandangs k ON k.id = pfk.kandang_id + WHERE k.location_id = sl.location_id + GROUP BY sl.mdp_id +), +kandang_totals AS ( + SELECT + COALESCE(SUM(sk.usage_qty), 0) AS total_pieces, + COALESCE(SUM(sk.total_weight), 0) AS total_weight + FROM sales_kandang sk +), +lokasi_adj_totals AS ( + SELECT + COALESCE(SUM( + sla.usage_qty * + CASE + WHEN COALESCE(ap.farm_usage_qty, 0) > 0 THEN (COALESCE(ap.sel_usage_qty, 0) * 1.0) / NULLIF(ap.farm_usage_qty, 0) + ELSE 0 + END + ), 0) AS total_pieces, + COALESCE(SUM( + sla.total_weight * + CASE + WHEN COALESCE(ap.farm_price_sum, 0) > 0 THEN (COALESCE(ap.sel_price_sum, 0) * 1.0) / NULLIF(ap.farm_price_sum, 0) + ELSE 0 + END + ), 0) AS total_weight + FROM sales_lokasi_adj sla + JOIN adj_pool ap ON ap.mdp_id = sla.mdp_id +), +lokasi_rec_totals AS ( + SELECT + COALESCE(SUM( + slr.usage_qty * + CASE + WHEN COALESCE(rp.farm_qty, 0) > 0 THEN (COALESCE(rp.sel_qty, 0) * 1.0) / NULLIF(rp.farm_qty, 0) + ELSE 0 + END + ), 0) AS total_pieces, + COALESCE(SUM( + slr.total_weight * + CASE + WHEN COALESCE(rp.farm_weight, 0) > 0 THEN (COALESCE(rp.sel_weight, 0) * 1.0) / NULLIF(rp.farm_weight, 0) + ELSE 0 + END + ), 0) AS total_weight + FROM sales_lokasi_rec slr + LEFT JOIN rec_pool rp ON rp.mdp_id = slr.mdp_id +) +SELECT + COALESCE(kt.total_pieces, 0) + COALESCE(lat.total_pieces, 0) + COALESCE(lrt.total_pieces, 0) AS total_pieces, + COALESCE(kt.total_weight, 0) + COALESCE(lat.total_weight, 0) + COALESCE(lrt.total_weight, 0) AS total_weight +FROM kandang_totals kt +CROSS JOIN lokasi_adj_totals lat +CROSS JOIN lokasi_rec_totals lrt +` + + var totals struct { + TotalPieces float64 + TotalWeight float64 + } + + err := r.db.WithContext(ctx). + Raw( + query, + projectFlockKandangIDs, + *startDate, + entity.FlagableTypeProduct, + eggFlags, + *startDate, + entity.FlagableTypeProduct, + eggFlags, + string(utils.AdjustmentTransactionSubtypeRecordingEggIn), + string(utils.AdjustmentTransactionTypeRecording), + ). + Scan(&totals).Error + + if err != nil { + return 0, 0, err + } + + return totals.TotalPieces, totals.TotalWeight, nil +} + +func (r *HppV2RepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) { + var summary struct { + ProjectFlockID uint + TotalQty float64 + } + err := r.db.WithContext(ctx). + Table("laying_transfer_targets AS ltt"). + Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty"). + Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id"). + Where("lt.deleted_at IS NULL"). + Where("ltt.deleted_at IS NULL"). + Where("lt.executed_at IS NOT NULL"). + Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId). + Group("lt.from_project_flock_id"). + Scan(&summary).Error + if err != nil { + return 0, 0, err + } + + return summary.ProjectFlockID, summary.TotalQty, nil +} diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go new file mode 100644 index 00000000..ea7375df --- /dev/null +++ b/internal/common/service/common.hppv2.service.go @@ -0,0 +1,163 @@ +package service + +import ( + "context" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" +) + +type HppV2Service interface { + CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) + GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetFeedGrowing(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetFeedLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) +} + +type hppV2Service struct { + hppRepo commonRepo.HppV2CostRepository +} + +func NewHppV2Service(hppRepo commonRepo.HppV2CostRepository) HppV2Service { + return &hppV2Service{hppRepo: hppRepo} +} + +func (s *hppV2Service) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) { + if date == nil { + now := time.Now() + date = &now + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, err + } + + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) + endOfDay := startOfDay.Add(24 * time.Hour) + + pakan, err := s.GetCostPakan(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + + result, err := s.GetHppEstimationDanRealisasi(pakan, projectFlockKandangId, &startOfDay, &endOfDay) + if err != nil { + return nil, err + } + + return result, nil +} + +func (s *hppV2Service) GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + feedGrowing, err := s.GetFeedGrowing(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + + feedLaying, err := s.GetFeedLaying(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + + pakan := feedGrowing + feedLaying + return pakan, nil +} + +func (s *hppV2Service) GetFeedGrowing(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + if s.hppRepo == nil { + return 0, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return 0, err + } + if sourceProjectFlockID == 0 || transferTotalQty <= 0 { + return 0, nil + } + + kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) + if err != nil { + return 0, err + } + if len(kandangIDsGrowing) == 0 { + return 0, nil + } + + feedUsageCostGrowing, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDsGrowing, endDate) + if err != nil { + return 0, err + } + + totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) + if err != nil { + return 0, err + } + if totalPopulationFlockGrowing == 0 { + return 0, nil + } + + result := feedUsageCostGrowing * (transferTotalQty / totalPopulationFlockGrowing) + return result, nil +} + +func (s *hppV2Service) GetFeedLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + if s.hppRepo == nil { + return 0, nil + } + + result, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) + if err != nil { + return 0, err + } + + return result, nil +} + +func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { + if s.hppRepo == nil { + return &HppCostResponse{}, nil + } + + estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) + if err != nil { + return nil, err + } + + realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate) + if err != nil { + return nil, err + } + + estimation := HppCostDetail{ + Total: totalProductionCost, + Kg: estimWeightKg, + Butir: estimPieces, + } + if estimWeightKg > 0 { + estimation.HargaKg = roundToTwoDecimals(totalProductionCost / estimWeightKg) + } + if estimPieces > 0 { + estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces) + } + + real := HppCostDetail{ + Total: totalProductionCost, + Kg: realWeightKg, + Butir: realPieces, + } + if realWeightKg > 0 { + real.HargaKg = roundToTwoDecimals(totalProductionCost / realWeightKg) + } + if realPieces > 0 { + real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) + } + + result := &HppCostResponse{ + Estimation: estimation, + Real: real, + } + return result, nil +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 59fd4b89..110bbc93 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -33,6 +33,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * recordingRepository := recordingRepo.NewRecordingRepository(db) approvalRepository := commonRepo.NewApprovalRepository(db) hppCostRepository := commonRepo.NewHppCostRepository(db) + hppV2CostRepository := commonRepo.NewHppV2CostRepository(db) purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) @@ -46,6 +47,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalSvc := approvalService.NewApprovalService(approvalRepository) hppSvc := approvalService.NewHppService(hppCostRepository) + hppV2Svc := approvalService.NewHppV2Service(hppV2CostRepository) repportService := sRepport.NewRepportService( db, validate, @@ -57,6 +59,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * recordingRepository, approvalSvc, hppSvc, + hppV2Svc, hppCostRepository, purchaseSupplierRepository, debtSupplierRepository, diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index ce3025db..f2473721 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -66,6 +66,7 @@ type repportService struct { RecordingRepo recordingRepo.RecordingRepository ApprovalSvc approvalService.ApprovalService HppSvc approvalService.HppService + HppV2Svc approvalService.HppV2Service HppCostRepo commonRepo.HppCostRepository PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository DebtSupplierRepo repportRepo.DebtSupplierRepository @@ -97,6 +98,7 @@ func NewRepportService( recordingRepo recordingRepo.RecordingRepository, approvalSvc approvalService.ApprovalService, hppSvc approvalService.HppService, + hppV2Svc approvalService.HppV2Service, hppCostRepo commonRepo.HppCostRepository, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, debtSupplierRepo repportRepo.DebtSupplierRepository, @@ -119,6 +121,7 @@ func NewRepportService( RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, HppSvc: hppSvc, + HppV2Svc: hppV2Svc, HppCostRepo: hppCostRepo, PurchaseSupplierRepo: purchaseSupplierRepo, DebtSupplierRepo: debtSupplierRepo, @@ -2289,8 +2292,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes var eggWeightFloat float64 var avgWeight float64 eggHpp := 0.0 - if s.HppSvc != nil { - hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate) + if s.HppV2Svc != nil { + hppCost, err := s.HppV2Svc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate) if err != nil { return nil, nil, err } From 58fbceea245d82793733d9f3435b490f4f9e9e2e Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Sun, 19 Apr 2026 14:06:42 +0700 Subject: [PATCH 3/8] feat: reimplement with plan hppv2 flow and logics --- .../repository/common.hppv2.repository.go | 338 +++++++- .../common.hppv2.repository_test.go | 248 ++++++ internal/common/service/common.hppv2.model.go | 56 ++ .../common/service/common.hppv2.service.go | 750 +++++++++++++++++- .../service/common.hppv2.service_test.go | 473 +++++++++++ .../controllers/repport.controller.go | 23 + internal/modules/repports/route.go | 1 + .../repports/services/repport.service.go | 22 + .../validations/repport.validation.go | 5 + 9 files changed, 1864 insertions(+), 52 deletions(-) create mode 100644 internal/common/repository/common.hppv2.repository_test.go create mode 100644 internal/common/service/common.hppv2.model.go create mode 100644 internal/common/service/common.hppv2.service_test.go diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index 74195f95..ccba7120 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -10,8 +11,61 @@ import ( "gorm.io/gorm" ) +type HppV2ProjectFlockKandangContext struct { + ProjectFlockKandangID uint + ProjectFlockID uint + ProjectFlockCategory string + KandangID uint + KandangName string + LocationID uint + HouseType string +} + +type HppV2UsageCostRow struct { + StockableType string + StockableID uint + SourceProductID uint + SourceProductName string + Qty float64 + UnitPrice float64 + TotalCost float64 + FirstUsedAt time.Time + LastUsedAt time.Time +} + +type HppV2AdjustmentCostRow struct { + AdjustmentID uint + ProjectFlockKandangID *uint + ProductWarehouseID uint + ProductID uint + ProductName string + WarehouseID uint + WarehouseType string + Qty float64 + Price float64 + GrandTotal float64 + CreatedAt time.Time +} + +type HppV2ExpenseCostRow struct { + ExpenseRealizationID uint + ExpenseNonstockID uint + ExpenseID uint + NonstockID uint + NonstockName string + Qty float64 + Price float64 + TotalCost float64 + RealizationDate time.Time +} + type HppV2CostRepository interface { + GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) + ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error) + ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error) + ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) + ListExpenseRealizationRowsByProjectFlockID(ctx context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) @@ -27,6 +81,33 @@ func NewHppV2CostRepository(db *gorm.DB) HppV2CostRepository { return &HppV2RepositoryImpl{db: db} } +func (r *HppV2RepositoryImpl) GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) { + var row HppV2ProjectFlockKandangContext + err := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + pfk.id AS project_flock_kandang_id, + pf.id AS project_flock_id, + pf.category AS project_flock_category, + k.id AS kandang_id, + k.name AS kandang_name, + k.location_id AS location_id, + k.house_type::text AS house_type + `). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pfk.id = ?", projectFlockKandangId). + Scan(&row).Error + if err != nil { + return nil, err + } + if row.ProjectFlockKandangID == 0 { + return nil, gorm.ErrRecordNotFound + } + + return &row, nil +} + func (r *HppV2RepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) { var ids []uint err := r.db.WithContext(ctx). @@ -41,6 +122,241 @@ func (r *HppV2RepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, pro return ids, nil } +func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, + date *time.Time, +) ([]HppV2UsageCostRow, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return []HppV2UsageCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + usableRecordingStock := fifo.UsableKeyRecordingStock.String() + + rows := make([]HppV2UsageCostRow, 0) + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + sa.stockable_type AS stockable_type, + sa.stockable_id AS stockable_id, + COALESCE(pi.product_id, ast_pw.product_id, 0) AS source_product_id, + COALESCE(pi_prod.name, ast_prod.name, '') AS source_product_name, + COALESCE(SUM(sa.qty), 0) AS qty, + CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END AS unit_price, + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + ELSE 0 + END), 0) AS total_cost, + MIN(r.record_datetime) AS first_used_at, + MAX(r.record_datetime) AS last_used_at + `, + stockablePurchase, + stockableAdjustment, + stockablePurchase, + stockableAdjustment, + ). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?", + usableRecordingStock, + stockablePurchase, + stockableAdjustment, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS pi_prod ON pi_prod.id = pi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id"). + Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id"). + Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames). + Group(` + sa.stockable_type, + sa.stockable_id, + COALESCE(pi.product_id, ast_pw.product_id, 0), + COALESCE(pi_prod.name, ast_prod.name, ''), + CASE + WHEN sa.stockable_type = '` + stockablePurchase + `' THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = '` + stockableAdjustment + `' THEN COALESCE(ast.price, 0) + ELSE 0 + END + `). + Order("MIN(r.record_datetime) ASC, sa.stockable_type ASC, sa.stockable_id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, + date *time.Time, +) ([]HppV2AdjustmentCostRow, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return []HppV2AdjustmentCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + rows := make([]HppV2AdjustmentCostRow, 0) + err := r.db.WithContext(ctx). + Table("adjustment_stocks AS ast"). + Select(` + ast.id AS adjustment_id, + pw.project_flock_kandang_id AS project_flock_kandang_id, + ast.product_warehouse_id AS product_warehouse_id, + pw.product_id AS product_id, + p.name AS product_name, + w.id AS warehouse_id, + w.type AS warehouse_type, + COALESCE(ast.total_qty, 0) AS qty, + COALESCE(ast.price, 0) AS price, + COALESCE(ast.grand_total, 0) AS grand_total, + ast.created_at AS created_at + `). + Joins("JOIN product_warehouses AS pw ON pw.id = ast.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("JOIN warehouses AS w ON w.id = pw.warehouse_id"). + Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("ast.created_at <= ?", *date). + Where("COALESCE(ast.total_qty, 0) > 0"). + Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames). + Order("ast.created_at ASC, ast.id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListExpenseRealizationRowsByProjectFlockKandangIDs( + ctx context.Context, + projectFlockKandangIDs []uint, + date *time.Time, + ekspedisi bool, +) ([]HppV2ExpenseCostRow, error) { + if len(projectFlockKandangIDs) == 0 { + return []HppV2ExpenseCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + rows := make([]HppV2ExpenseCostRow, 0) + query := r.db.WithContext(ctx). + Table("expense_realizations AS er"). + Select(` + er.id AS expense_realization_id, + en.id AS expense_nonstock_id, + e.id AS expense_id, + COALESCE(n.id, 0) AS nonstock_id, + COALESCE(n.name, '') AS nonstock_name, + COALESCE(er.qty, 0) AS qty, + COALESCE(er.price, 0) AS price, + COALESCE(er.qty, 0) * COALESCE(er.price, 0) AS total_cost, + COALESCE(e.realization_date, DATE(er.created_at)) AS realization_date + `). + Joins("JOIN expense_nonstocks AS en ON en.id = er.expense_nonstock_id"). + Joins("JOIN expenses AS e ON e.id = en.expense_id"). + Joins("LEFT JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ? AND f.name = ?", entity.FlagableTypeNonstock, utils.FlagEkspedisi). + Where("e.deleted_at IS NULL"). + Where("e.category = ?", utils.ExpenseCategoryBOP). + Where("en.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("COALESCE(e.realization_date, DATE(er.created_at)) <= ?", *date) + + if ekspedisi { + query = query.Where("f.id IS NOT NULL") + } else { + query = query.Where("f.id IS NULL") + } + + if err := query. + Order("COALESCE(e.realization_date, DATE(er.created_at)) ASC, er.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *HppV2RepositoryImpl) ListExpenseRealizationRowsByProjectFlockID( + ctx context.Context, + projectFlockID uint, + date *time.Time, + ekspedisi bool, +) ([]HppV2ExpenseCostRow, error) { + if projectFlockID == 0 { + return []HppV2ExpenseCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + rows := make([]HppV2ExpenseCostRow, 0) + query := r.db.WithContext(ctx). + Table("expense_realizations AS er"). + Select(` + er.id AS expense_realization_id, + en.id AS expense_nonstock_id, + e.id AS expense_id, + COALESCE(n.id, 0) AS nonstock_id, + COALESCE(n.name, '') AS nonstock_name, + COALESCE(er.qty, 0) AS qty, + COALESCE(er.price, 0) AS price, + COALESCE(er.qty, 0) * COALESCE(er.price, 0) AS total_cost, + COALESCE(e.realization_date, DATE(er.created_at)) AS realization_date + `). + Joins("JOIN expense_nonstocks AS en ON en.id = er.expense_nonstock_id"). + Joins("JOIN expenses AS e ON e.id = en.expense_id"). + Joins("LEFT JOIN nonstocks AS n ON n.id = en.nonstock_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = n.id AND f.flagable_type = ? AND f.name = ?", entity.FlagableTypeNonstock, utils.FlagEkspedisi). + Where("e.deleted_at IS NULL"). + Where("e.category = ?", utils.ExpenseCategoryBOP). + Where("en.project_flock_kandang_id IS NULL"). + Where("e.project_flock_id IS NOT NULL"). + Where("e.project_flock_id::jsonb @> ?::jsonb", fmt.Sprintf("[%d]", projectFlockID)). + Where("COALESCE(e.realization_date, DATE(er.created_at)) <= ?", *date) + + if ekspedisi { + query = query.Where("f.id IS NOT NULL") + } else { + query = query.Where("f.id IS NULL") + } + + if err := query. + Order("COALESCE(e.realization_date, DATE(er.created_at)) ASC, er.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + func (r *HppV2RepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) { if date == nil { now := time.Now() @@ -122,10 +438,13 @@ func (r *HppV2RepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKanda return 0, 0, err } - var adjustmentTotalWeight float64 + var adjustmentTotals struct { + TotalQty float64 + TotalWeight float64 + } adjustmentSubQuery := r.db.WithContext(ctx). Table("recordings AS r"). - Select("DISTINCT ast.id AS adjustment_id, ast.price AS price"). + Select("DISTINCT ast.id AS adjustment_id, ast.total_qty AS total_qty, ast.price AS price"). Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id"). Joins( @@ -141,13 +460,14 @@ func (r *HppV2RepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKanda err = r.db.WithContext(ctx). Table("(?) AS adjustment_sources", adjustmentSubQuery). - Select("COALESCE(SUM(adjustment_sources.price), 0)"). - Scan(&adjustmentTotalWeight).Error + Select("COALESCE(SUM(adjustment_sources.total_qty), 0) AS total_qty, COALESCE(SUM(adjustment_sources.price), 0) AS total_weight"). + Scan(&adjustmentTotals).Error if err != nil { return 0, 0, err } - totals.TotalWeightKg += adjustmentTotalWeight + totals.TotalPieces += adjustmentTotals.TotalQty + totals.TotalWeightKg += adjustmentTotals.TotalWeight return totals.TotalPieces, totals.TotalWeightKg, nil } @@ -200,7 +520,7 @@ sales_kandang AS ( JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN warehouses w ON w.id = pw.warehouse_id WHERE mdp.delivery_date IS NOT NULL - AND mdp.delivery_date <= ? + AND mdp.delivery_date <= ? AND UPPER(COALESCE(w.type, '')) = 'KANDANG' AND pw.project_flock_kandang_id IN (SELECT id FROM selected_pfk) AND EXISTS ( @@ -234,7 +554,7 @@ sales_lokasi AS ( JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN warehouses w ON w.id = pw.warehouse_id WHERE mdp.delivery_date IS NOT NULL - AND mdp.delivery_date <= ? + AND mdp.delivery_date <= ? AND UPPER(COALESCE(w.type, '')) = 'LOKASI' AND w.location_id IN (SELECT location_id FROM selected_locations) AND EXISTS ( @@ -390,10 +710,10 @@ CROSS JOIN lokasi_rec_totals lrt Raw( query, projectFlockKandangIDs, - *startDate, + *endDate, entity.FlagableTypeProduct, eggFlags, - *startDate, + *endDate, entity.FlagableTypeProduct, eggFlags, string(utils.AdjustmentTransactionSubtypeRecordingEggIn), diff --git a/internal/common/repository/common.hppv2.repository_test.go b/internal/common/repository/common.hppv2.repository_test.go new file mode 100644 index 00000000..7aefad44 --- /dev/null +++ b/internal/common/repository/common.hppv2.repository_test.go @@ -0,0 +1,248 @@ +package repository + +import ( + "context" + "math" + "testing" + "time" + + "github.com/glebarez/sqlite" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +func TestHppV2RepositoryGetEggProduksiIncludesTransferredAdjustmentStock(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime) VALUES (1, 101, '2026-04-19 10:00:00')`, + `INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 401, 80, 8, 101)`, + `INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00')`, + `INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 301, 401)`, + `INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose) VALUES (1, 'STOCK_TRANSFER_OUT', 1, 'ADJUSTMENT_IN', 501, 'ACTIVE', 'CONSUME')`, + `INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, price, created_at) VALUES (501, 301, 20, 2.5, '2026-04-18 07:30:00')`, + ) + + repo := &HppV2RepositoryImpl{db: db} + endDate := mustJakartaTime(t, "2026-04-20 00:00:00") + + totalPieces, totalWeightKg, err := repo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &endDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertFloatEquals(t, totalPieces, 100) + assertFloatEquals(t, totalWeightKg, 10.5) +} + +func TestHppV2RepositoryGetEggTerjualUsesEndDateForSameDayFarmSales(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`, + `INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`, + `INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10)`, + `INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL)`, + `INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`, + `INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES (1, 101, '2026-04-19 08:00:00', NULL), (2, 102, '2026-04-19 09:00:00', NULL)`, + `INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 301, 60, 6, 101), (2, 2, 301, 40, 4, 102)`, + `INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`, + `INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 50, 5, '2026-04-19 12:00:00')`, + ) + + repo := &HppV2RepositoryImpl{db: db} + startDate := mustJakartaTime(t, "2026-04-19 00:00:00") + endDate := mustJakartaTime(t, "2026-04-20 00:00:00") + + totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertFloatEquals(t, totalPieces, 30) + assertFloatEquals(t, totalWeightKg, 3) +} + +func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(t *testing.T) { + db := setupHppV2RepositoryTestDB(t) + + mustExecHppV2(t, db, + `INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`, + `INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`, + `INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10), (211, 'KANDANG', 10), (212, 'KANDANG', 10)`, + `INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL), (311, 211, 900, 101), (312, 212, 900, 102)`, + `INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`, + `INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00'), (2, '2026-04-18 08:15:00')`, + `INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 311, 301), (2, 2, 312, 301)`, + `INSERT INTO adjustment_stocks (id, product_warehouse_id, usage_qty, price, function_code, transaction_type, created_at) VALUES + (801, 311, 70, 7, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:00:00'), + (802, 312, 30, 3, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:30:00')`, + `INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`, + `INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 20, 2, '2026-04-19 12:00:00')`, + ) + + repo := &HppV2RepositoryImpl{db: db} + startDate := mustJakartaTime(t, "2026-04-19 00:00:00") + endDate := mustJakartaTime(t, "2026-04-20 00:00:00") + + totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertFloatEquals(t, totalPieces, 14) + assertFloatEquals(t, totalWeightKg, 1.4) +} + +func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + mustExecHppV2(t, db, + `CREATE TABLE recordings ( + id INTEGER PRIMARY KEY, + project_flock_kandangs_id INTEGER NULL, + record_datetime DATETIME NULL, + deleted_at DATETIME NULL + )`, + `CREATE TABLE recording_eggs ( + id INTEGER PRIMARY KEY, + recording_id INTEGER NULL, + product_warehouse_id INTEGER NULL, + qty NUMERIC(15,3) NULL, + weight NUMERIC(15,3) NULL, + project_flock_kandang_id INTEGER NULL + )`, + `CREATE TABLE stock_transfers ( + id INTEGER PRIMARY KEY, + transfer_date DATETIME NULL + )`, + `CREATE TABLE stock_transfer_details ( + id INTEGER PRIMARY KEY, + stock_transfer_id INTEGER NULL, + source_product_warehouse_id INTEGER NULL, + dest_product_warehouse_id INTEGER NULL + )`, + `CREATE TABLE stock_allocations ( + id INTEGER PRIMARY KEY, + usable_type TEXT NULL, + usable_id INTEGER NULL, + stockable_type TEXT NULL, + stockable_id INTEGER NULL, + status TEXT NULL, + allocation_purpose TEXT NULL, + qty NUMERIC(15,3) NULL + )`, + `CREATE TABLE adjustment_stocks ( + id INTEGER PRIMARY KEY, + product_warehouse_id INTEGER NULL, + total_qty NUMERIC(15,3) NULL, + usage_qty NUMERIC(15,3) NULL, + price NUMERIC(15,3) NULL, + grand_total NUMERIC(15,3) NULL, + function_code TEXT NULL, + transaction_type TEXT NULL, + created_at DATETIME NULL + )`, + `CREATE TABLE kandangs ( + id INTEGER PRIMARY KEY, + location_id INTEGER NULL + )`, + `CREATE TABLE project_flock_kandangs ( + id INTEGER PRIMARY KEY, + kandang_id INTEGER NULL, + project_flock_id INTEGER NULL + )`, + `CREATE TABLE warehouses ( + id INTEGER PRIMARY KEY, + type TEXT NULL, + location_id INTEGER NULL + )`, + `CREATE TABLE product_warehouses ( + id INTEGER PRIMARY KEY, + warehouse_id INTEGER NULL, + product_id INTEGER NULL, + project_flock_kandang_id INTEGER NULL + )`, + `CREATE TABLE marketing_products ( + id INTEGER PRIMARY KEY, + product_warehouse_id INTEGER NULL + )`, + `CREATE TABLE marketing_delivery_products ( + id INTEGER PRIMARY KEY, + marketing_product_id INTEGER NULL, + usage_qty NUMERIC(15,3) NULL, + total_weight NUMERIC(15,3) NULL, + delivery_date DATETIME NULL + )`, + `CREATE TABLE flags ( + id INTEGER PRIMARY KEY, + flagable_type TEXT NULL, + flagable_id INTEGER NULL, + name TEXT NULL + )`, + ) + + return db +} + +func mustExecHppV2(t *testing.T, db *gorm.DB, statements ...string) { + t.Helper() + + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("failed executing statement %q: %v", statement, err) + } + } +} + +func mustJakartaTime(t *testing.T, raw string) time.Time { + t.Helper() + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading timezone: %v", err) + } + + value, err := time.ParseInLocation("2006-01-02 15:04:05", raw, location) + if err != nil { + t.Fatalf("failed parsing time %q: %v", raw, err) + } + + return value +} + +func assertFloatEquals(t *testing.T, got float64, want float64) { + t.Helper() + + if math.Abs(got-want) > 0.000001 { + t.Fatalf("expected %.6f, got %.6f", want, got) + } +} + +func TestHppV2RepositoryConstantsStayAlignedWithProductionQueries(t *testing.T) { + if fifo.UsableKeyStockTransferOut.String() != "STOCK_TRANSFER_OUT" { + t.Fatalf("unexpected stock transfer usable key: %s", fifo.UsableKeyStockTransferOut.String()) + } + if fifo.StockableKeyAdjustmentIn.String() != "ADJUSTMENT_IN" { + t.Fatalf("unexpected adjustment stockable key: %s", fifo.StockableKeyAdjustmentIn.String()) + } + if entity.StockAllocationStatusActive != "ACTIVE" { + t.Fatalf("unexpected active stock allocation status: %s", entity.StockAllocationStatusActive) + } + if entity.StockAllocationPurposeConsume != "CONSUME" { + t.Fatalf("unexpected consume stock allocation purpose: %s", entity.StockAllocationPurposeConsume) + } + if string(utils.AdjustmentTransactionSubtypeRecordingEggIn) != "RECORDING_EGG_IN" { + t.Fatalf("unexpected adjustment function code: %s", utils.AdjustmentTransactionSubtypeRecordingEggIn) + } + if string(utils.AdjustmentTransactionTypeRecording) != "RECORDING" { + t.Fatalf("unexpected adjustment transaction type: %s", utils.AdjustmentTransactionTypeRecording) + } +} diff --git a/internal/common/service/common.hppv2.model.go b/internal/common/service/common.hppv2.model.go new file mode 100644 index 00000000..134ecb81 --- /dev/null +++ b/internal/common/service/common.hppv2.model.go @@ -0,0 +1,56 @@ +package service + +type HppV2DateWindow struct { + Start string `json:"start"` + End string `json:"end"` +} + +type HppV2Proration struct { + Basis string `json:"basis"` + Numerator float64 `json:"numerator"` + Denominator float64 `json:"denominator"` + Ratio float64 `json:"ratio"` +} + +type HppV2Reference struct { + Type string `json:"type"` + ID uint `json:"id"` + StockableType string `json:"stockable_type,omitempty"` + ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"` + ProductID uint `json:"product_id,omitempty"` + ProductName string `json:"product_name,omitempty"` + Date string `json:"date,omitempty"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + Total float64 `json:"total"` + AppliedTotal float64 `json:"applied_total"` +} + +type HppV2ComponentPart struct { + Code string `json:"code"` + Title string `json:"title"` + Total float64 `json:"total"` + Proration *HppV2Proration `json:"proration,omitempty"` + References []HppV2Reference `json:"references,omitempty"` +} + +type HppV2Component struct { + Code string `json:"code"` + Title string `json:"title"` + Total float64 `json:"total"` + Parts []HppV2ComponentPart `json:"parts"` +} + +type HppV2Breakdown struct { + ProjectFlockKandangID uint `json:"project_flock_kandang_id"` + ProjectFlockID uint `json:"project_flock_id"` + ProjectFlockCategory string `json:"project_flock_category,omitempty"` + KandangID uint `json:"kandang_id,omitempty"` + KandangName string `json:"kandang_name,omitempty"` + LocationID uint `json:"location_id,omitempty"` + PeriodDate string `json:"period_date"` + Window HppV2DateWindow `json:"window"` + TotalProductionCost float64 `json:"total_production_cost"` + Components []HppV2Component `json:"components"` + Hpp HppCostResponse `json:"hpp"` +} diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index ea7375df..0bde1dfa 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -5,13 +5,40 @@ import ( "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +const ( + hppV2ComponentPakan = "PAKAN" + hppV2ComponentOvk = "OVK" + hppV2ComponentBopRegular = "BOP_REGULAR" + hppV2ComponentBopEksp = "BOP_EKSPEDISI" + hppV2PartGrowingNormal = "growing_normal" + hppV2PartGrowingCutover = "growing_cutover" + hppV2PartLayingNormal = "laying_normal" + hppV2PartLayingCutover = "laying_cutover" + hppV2PartGrowingDirect = "growing_direct" + hppV2PartGrowingFarm = "growing_farm" + hppV2PartLayingDirect = "laying_direct" + hppV2PartLayingFarm = "laying_farm" + hppV2ProrationPopulation = "growing_population_share" + hppV2ProrationEggWeight = "laying_egg_weight_share" + hppV2ProrationEggPiece = "laying_egg_piece_share" + hppV2CutoverFlagPakan = "PAKAN-CUTOVER" + hppV2CutoverFlagOvk = "OVK-CUTOVER" ) type HppV2Service interface { CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) + CalculateHppBreakdown(projectFlockKandangId uint, date *time.Time) (*HppV2Breakdown, error) GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) - GetFeedGrowing(projectFlockKandangId uint, endDate *time.Time) (float64, error) - GetFeedLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostBopEkspedisi(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetPakanBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetOvkBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) } @@ -19,101 +46,676 @@ type hppV2Service struct { hppRepo commonRepo.HppV2CostRepository } +type hppV2StockComponentConfig struct { + Code string + Title string + NormalFlags []string + CutoverFlags []string +} + +type hppV2ExpenseComponentConfig struct { + Code string + Title string + Ekspedisi bool +} + func NewHppV2Service(hppRepo commonRepo.HppV2CostRepository) HppV2Service { return &hppV2Service{hppRepo: hppRepo} } func (s *hppV2Service) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) { - if date == nil { - now := time.Now() - date = &now + breakdown, err := s.CalculateHppBreakdown(projectFlockKandangId, date) + if err != nil { + return nil, err + } + if breakdown == nil { + return &HppCostResponse{}, nil } - location, err := time.LoadLocation("Asia/Jakarta") + result := breakdown.Hpp + return &result, nil +} + +func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *time.Time) (*HppV2Breakdown, error) { + if s.hppRepo == nil { + return &HppV2Breakdown{ + ProjectFlockKandangID: projectFlockKandangId, + Hpp: HppCostResponse{}, + }, nil + } + + startOfDay, endOfDay, err := hppV2DayWindow(date) if err != nil { return nil, err } - startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) - endOfDay := startOfDay.Add(24 * time.Hour) - - pakan, err := s.GetCostPakan(projectFlockKandangId, &endOfDay) + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) if err != nil { return nil, err } - result, err := s.GetHppEstimationDanRealisasi(pakan, projectFlockKandangId, &startOfDay, &endOfDay) + pakanComponent, err := s.GetPakanBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - return result, nil + totalProductionCost := 0.0 + components := make([]HppV2Component, 0, 4) + if pakanComponent != nil && (pakanComponent.Total > 0 || len(pakanComponent.Parts) > 0) { + totalProductionCost += pakanComponent.Total + components = append(components, *pakanComponent) + } + + ovkComponent, err := s.GetOvkBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + if ovkComponent != nil && (ovkComponent.Total > 0 || len(ovkComponent.Parts) > 0) { + totalProductionCost += ovkComponent.Total + components = append(components, *ovkComponent) + } + + bopRegularComponent, err := s.GetBopRegularBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + if bopRegularComponent != nil && (bopRegularComponent.Total > 0 || len(bopRegularComponent.Parts) > 0) { + totalProductionCost += bopRegularComponent.Total + components = append(components, *bopRegularComponent) + } + + bopEkspedisiComponent, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + if bopEkspedisiComponent != nil && (bopEkspedisiComponent.Total > 0 || len(bopEkspedisiComponent.Parts) > 0) { + totalProductionCost += bopEkspedisiComponent.Total + components = append(components, *bopEkspedisiComponent) + } + + hppCost, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) + if err != nil { + return nil, err + } + if hppCost == nil { + hppCost = &HppCostResponse{} + } + + return &HppV2Breakdown{ + ProjectFlockKandangID: projectFlockKandangId, + ProjectFlockID: contextRow.ProjectFlockID, + ProjectFlockCategory: contextRow.ProjectFlockCategory, + KandangID: contextRow.KandangID, + KandangName: contextRow.KandangName, + LocationID: contextRow.LocationID, + PeriodDate: startOfDay.Format("2006-01-02"), + Window: HppV2DateWindow{ + Start: startOfDay.Format(time.RFC3339), + End: endOfDay.Format(time.RFC3339), + }, + TotalProductionCost: totalProductionCost, + Components: components, + Hpp: *hppCost, + }, nil } func (s *hppV2Service) GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - feedGrowing, err := s.GetFeedGrowing(projectFlockKandangId, endDate) + component, err := s.GetPakanBreakdown(projectFlockKandangId, endDate) if err != nil { return 0, err } - - feedLaying, err := s.GetFeedLaying(projectFlockKandangId, endDate) - if err != nil { - return 0, err + if component == nil { + return 0, nil } - pakan := feedGrowing + feedLaying - return pakan, nil + return component.Total, nil } -func (s *hppV2Service) GetFeedGrowing(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - if s.hppRepo == nil { +func (s *hppV2Service) GetPakanBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getStockUsageComponent(projectFlockKandangId, endDate, hppV2StockComponentConfig{ + Code: hppV2ComponentPakan, + Title: "Pakan", + NormalFlags: []string{string(utils.FlagPakan)}, + CutoverFlags: []string{hppV2CutoverFlagPakan}, + }) +} + +func (s *hppV2Service) GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetOvkBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { return 0, nil } + return component.Total, nil +} + +func (s *hppV2Service) GetOvkBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getStockUsageComponent(projectFlockKandangId, endDate, hppV2StockComponentConfig{ + Code: hppV2ComponentOvk, + Title: "OVK", + NormalFlags: []string{ + string(utils.FlagOVK), + string(utils.FlagObat), + string(utils.FlagVitamin), + string(utils.FlagKimia), + }, + CutoverFlags: []string{hppV2CutoverFlagOvk}, + }) +} + +func (s *hppV2Service) GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetBopRegularBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetCostBopEkspedisi(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getExpenseComponent(projectFlockKandangId, endDate, hppV2ExpenseComponentConfig{ + Code: hppV2ComponentBopRegular, + Title: "BOP Regular", + Ekspedisi: false, + }) +} + +func (s *hppV2Service) GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + return s.getExpenseComponent(projectFlockKandangId, endDate, hppV2ExpenseComponentConfig{ + Code: hppV2ComponentBopEksp, + Title: "BOP Ekspedisi", + Ekspedisi: true, + }) +} + +func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2StockComponentConfig) (*HppV2Component, error) { + if s.hppRepo == nil { + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Parts: []HppV2ComponentPart{}, + }, nil + } + + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 4) + total := 0.0 + + growingPart, err := s.buildGrowingUsagePart(projectFlockKandangId, contextRow, endDate, config, false) + if err != nil { + return nil, err + } + if growingPart != nil { + parts = append(parts, *growingPart) + total += growingPart.Total + } + + growingCutoverPart, err := s.buildGrowingUsagePart(projectFlockKandangId, contextRow, endDate, config, true) + if err != nil { + return nil, err + } + if growingCutoverPart != nil { + parts = append(parts, *growingCutoverPart) + total += growingCutoverPart.Total + } + + layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, false) + if err != nil { + return nil, err + } + if layingNormalPart != nil { + parts = append(parts, *layingNormalPart) + total += layingNormalPart.Total + } + + layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, true) + if err != nil { + return nil, err + } + if layingCutoverPart != nil { + parts = append(parts, *layingCutoverPart) + total += layingCutoverPart.Total + } + + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Total: total, + Parts: parts, + }, nil +} + +func (s *hppV2Service) getExpenseComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2ExpenseComponentConfig) (*HppV2Component, error) { + if s.hppRepo == nil { + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Parts: []HppV2ComponentPart{}, + }, nil + } + + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 4) + total := 0.0 + + growingDirect, err := s.buildGrowingExpenseDirectPart(projectFlockKandangId, contextRow, endDate, config) + if err != nil { + return nil, err + } + if growingDirect != nil { + parts = append(parts, *growingDirect) + total += growingDirect.Total + } + + growingFarm, err := s.buildGrowingExpenseFarmPart(projectFlockKandangId, contextRow, endDate, config) + if err != nil { + return nil, err + } + if growingFarm != nil { + parts = append(parts, *growingFarm) + total += growingFarm.Total + } + + layingDirect, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, endDate, config) + if err != nil { + return nil, err + } + if layingDirect != nil { + parts = append(parts, *layingDirect) + total += layingDirect.Total + } + + layingFarm, err := s.buildLayingExpenseFarmPart(projectFlockKandangId, contextRow, endDate, config) + if err != nil { + return nil, err + } + if layingFarm != nil { + parts = append(parts, *layingFarm) + total += layingFarm.Total + } + + return &HppV2Component{ + Code: config.Code, + Title: config.Title, + Total: total, + Parts: parts, + }, nil +} + +func (s *hppV2Service) buildGrowingUsagePart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2StockComponentConfig, + cutover bool, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) if err != nil { - return 0, err + return nil, err } if sourceProjectFlockID == 0 || transferTotalQty <= 0 { - return 0, nil + return nil, nil } kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) if err != nil { - return 0, err + return nil, err } if len(kandangIDsGrowing) == 0 { - return 0, nil - } - - feedUsageCostGrowing, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDsGrowing, endDate) - if err != nil { - return 0, err + return nil, nil } totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) if err != nil { - return 0, err + return nil, err } if totalPopulationFlockGrowing == 0 { - return 0, nil + return nil, nil } - result := feedUsageCostGrowing * (transferTotalQty / totalPopulationFlockGrowing) - return result, nil + ratio := transferTotalQty / totalPopulationFlockGrowing + if ratio <= 0 { + return nil, nil + } + + partCode := hppV2PartGrowingNormal + partTitle := "Growing" + baseRows := make([]HppV2Reference, 0) + baseTotal := 0.0 + + if cutover { + partCode = hppV2PartGrowingCutover + partTitle = "Growing Cut-over" + + rows, err := s.hppRepo.ListAdjustmentCostRowsByProductFlags(context.Background(), kandangIDsGrowing, config.CutoverFlags, endDate) + if err != nil { + return nil, err + } + for _, row := range rows { + rowTotal := adjustmentRowTotalCost(row) + baseTotal += rowTotal + baseRows = append(baseRows, HppV2Reference{ + Type: "adjustment_stock", + ID: row.AdjustmentID, + ProjectFlockKandangID: row.ProjectFlockKandangID, + ProductID: row.ProductID, + ProductName: row.ProductName, + Date: row.CreatedAt.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.Price, + Total: rowTotal, + AppliedTotal: rowTotal * ratio, + }) + } + } else { + rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), kandangIDsGrowing, config.NormalFlags, endDate) + if err != nil { + return nil, err + } + for _, row := range rows { + baseTotal += row.TotalCost + refDate := row.LastUsedAt + if refDate.IsZero() { + refDate = row.FirstUsedAt + } + baseRows = append(baseRows, HppV2Reference{ + Type: "stock_allocation", + ID: row.StockableID, + StockableType: row.StockableType, + ProductID: row.SourceProductID, + ProductName: row.SourceProductName, + Date: refDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.UnitPrice, + Total: row.TotalCost, + AppliedTotal: row.TotalCost * ratio, + }) + } + } + + if baseTotal == 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: partCode, + Title: partTitle, + Total: baseTotal * ratio, + Proration: &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: transferTotalQty, + Denominator: totalPopulationFlockGrowing, + Ratio: ratio, + }, + References: baseRows, + }, nil } -func (s *hppV2Service) GetFeedLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - if s.hppRepo == nil { - return 0, nil +func (s *hppV2Service) buildLayingUsagePart( + projectFlockKandangId uint, + endDate *time.Time, + config hppV2StockComponentConfig, + cutover bool, +) (*HppV2ComponentPart, error) { + if cutover { + rows, err := s.hppRepo.ListAdjustmentCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.CutoverFlags, endDate) + if err != nil { + return nil, err + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + rowTotal := adjustmentRowTotalCost(row) + total += rowTotal + references = append(references, HppV2Reference{ + Type: "adjustment_stock", + ID: row.AdjustmentID, + ProjectFlockKandangID: row.ProjectFlockKandangID, + ProductID: row.ProductID, + ProductName: row.ProductName, + Date: row.CreatedAt.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.Price, + Total: rowTotal, + AppliedTotal: rowTotal, + }) + } + if total == 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartLayingCutover, + Title: "Laying Cut-over", + Total: total, + References: references, + }, nil } - result, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) + rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate) if err != nil { - return 0, err + return nil, err } - return result, nil + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + total += row.TotalCost + refDate := row.LastUsedAt + if refDate.IsZero() { + refDate = row.FirstUsedAt + } + references = append(references, HppV2Reference{ + Type: "stock_allocation", + ID: row.StockableID, + StockableType: row.StockableType, + ProductID: row.SourceProductID, + ProductName: row.SourceProductName, + Date: refDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.UnitPrice, + Total: row.TotalCost, + AppliedTotal: row.TotalCost, + }) + } + if total == 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartLayingNormal, + Title: "Laying", + Total: total, + References: references, + }, nil +} + +func (s *hppV2Service) buildGrowingExpenseDirectPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + return s.buildGrowingExpensePart(projectFlockKandangId, contextRow, endDate, config, false) +} + +func (s *hppV2Service) buildGrowingExpenseFarmPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + return s.buildGrowingExpensePart(projectFlockKandangId, contextRow, endDate, config, true) +} + +func (s *hppV2Service) buildGrowingExpensePart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, + farmLevel bool, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + if sourceProjectFlockID == 0 || transferTotalQty <= 0 { + return nil, nil + } + + kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) + if err != nil { + return nil, err + } + if len(kandangIDsGrowing) == 0 { + return nil, nil + } + + totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) + if err != nil { + return nil, err + } + if totalPopulationFlockGrowing <= 0 { + return nil, nil + } + + ratio := transferTotalQty / totalPopulationFlockGrowing + if ratio <= 0 { + return nil, nil + } + + var rows []commonRepo.HppV2ExpenseCostRow + if farmLevel { + rows, err = s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), sourceProjectFlockID, endDate, config.Ekspedisi) + } else { + rows, err = s.hppRepo.ListExpenseRealizationRowsByProjectFlockKandangIDs(context.Background(), kandangIDsGrowing, endDate, config.Ekspedisi) + } + if err != nil { + return nil, err + } + + return buildExpensePartFromRows( + rows, + map[bool]string{false: hppV2PartGrowingDirect, true: hppV2PartGrowingFarm}[farmLevel], + map[bool]string{false: "Growing Direct", true: "Growing Farm"}[farmLevel], + &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: transferTotalQty, + Denominator: totalPopulationFlockGrowing, + Ratio: ratio, + }, + ratio, + ), nil +} + +func (s *hppV2Service) buildLayingExpenseDirectPart( + projectFlockKandangId uint, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + rows, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockKandangIDs(context.Background(), []uint{projectFlockKandangId}, endDate, config.Ekspedisi) + if err != nil { + return nil, err + } + + return buildExpensePartFromRows(rows, hppV2PartLayingDirect, "Laying Direct", nil, 1), nil +} + +func (s *hppV2Service) buildLayingExpenseFarmPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + config hppV2ExpenseComponentConfig, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + rows, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, endDate, config.Ekspedisi) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + + farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) + if err != nil { + return nil, err + } + farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate) + if err != nil { + return nil, err + } + + basis := hppV2ProrationEggWeight + numerator := targetWeight + denominator := farmWeight + if denominator <= 0 { + basis = hppV2ProrationEggPiece + numerator = targetPieces + denominator = farmPieces + } + if denominator <= 0 { + return nil, nil + } + + ratio := numerator / denominator + if ratio <= 0 { + return nil, nil + } + + return buildExpensePartFromRows( + rows, + hppV2PartLayingFarm, + "Laying Farm", + &HppV2Proration{ + Basis: basis, + Numerator: numerator, + Denominator: denominator, + Ratio: ratio, + }, + ratio, + ), nil } func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { @@ -155,9 +757,71 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) } - result := &HppCostResponse{ + return &HppCostResponse{ Estimation: estimation, Real: real, - } - return result, nil + }, nil +} + +func hppV2DayWindow(date *time.Time) (time.Time, time.Time, error) { + if date == nil { + now := time.Now() + date = &now + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return time.Time{}, time.Time{}, err + } + + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) + endOfDay := startOfDay.Add(24 * time.Hour) + return startOfDay, endOfDay, nil +} + +func adjustmentRowTotalCost(row commonRepo.HppV2AdjustmentCostRow) float64 { + if row.GrandTotal > 0 { + return row.GrandTotal + } + return row.Qty * row.Price +} + +func buildExpensePartFromRows( + rows []commonRepo.HppV2ExpenseCostRow, + code string, + title string, + proration *HppV2Proration, + ratio float64, +) *HppV2ComponentPart { + if len(rows) == 0 { + return nil + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + total += row.TotalCost * ratio + references = append(references, HppV2Reference{ + Type: "expense_realization", + ID: row.ExpenseRealizationID, + ProductID: row.NonstockID, + ProductName: row.NonstockName, + Date: row.RealizationDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.Price, + Total: row.TotalCost, + AppliedTotal: row.TotalCost * ratio, + }) + } + if total == 0 { + return nil + } + + return &HppV2ComponentPart{ + Code: code, + Title: title, + Total: total, + Proration: proration, + References: references, + } } diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go new file mode 100644 index 00000000..1a7aa2a1 --- /dev/null +++ b/internal/common/service/common.hppv2.service_test.go @@ -0,0 +1,473 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" + "testing" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type hppV2RepoStub struct { + contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext + pfkIDsByProject map[uint][]uint + usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow + adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow + expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow + expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow + totalPopulationByKey map[string]float64 + transferSummaryByPFK map[uint]struct { + projectFlockID uint + totalQty float64 + } + eggProductionByPFK map[uint]struct { + pieces float64 + kg float64 + } + eggSalesByPFK map[uint]struct { + pieces float64 + kg float64 + } +} + +func (s *hppV2RepoStub) GetProjectFlockKandangContext(_ context.Context, projectFlockKandangId uint) (*commonRepo.HppV2ProjectFlockKandangContext, error) { + row := s.contextByPFK[projectFlockKandangId] + if row == nil { + return nil, fmt.Errorf("pfk %d not found", projectFlockKandangId) + } + return row, nil +} + +func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFlockId uint) ([]uint, error) { + return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil +} + +func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) { + return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil +} + +func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) { + return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil +} + +func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) { + return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil +} + +func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) { + return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil +} + +func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) { + return 0, nil +} + +func (s *hppV2RepoStub) GetTotalPopulation(_ context.Context, projectFlockKandangIDs []uint) (float64, error) { + return s.totalPopulationByKey[stubKey(projectFlockKandangIDs, nil)], nil +} + +func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, error) { + totalPieces := 0.0 + totalKg := 0.0 + for _, projectFlockKandangID := range projectFlockKandangIDs { + row := s.eggProductionByPFK[projectFlockKandangID] + totalPieces += row.pieces + totalKg += row.kg + } + return totalPieces, totalKg, nil +} + +func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) { + if len(projectFlockKandangIDs) != 1 { + return 0, 0, nil + } + row := s.eggSalesByPFK[projectFlockKandangIDs[0]] + return row.pieces, row.kg, nil +} + +func (s *hppV2RepoStub) GetTransferSourceSummary(_ context.Context, projectFlockKandangId uint) (uint, float64, error) { + row := s.transferSummaryByPFK[projectFlockKandangId] + return row.projectFlockID, row.totalQty, nil +} + +func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 10: { + ProjectFlockKandangID: 10, + ProjectFlockID: 2, + ProjectFlockCategory: "LAYING", + KandangID: 100, + KandangName: "Kandang A", + LocationID: 16, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 1: {101, 102}, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{101, 102}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9001, SourceProductID: 8, SourceProductName: "Pakan Growing", Qty: 100, UnitPrice: 40, TotalCost: 4000}, + }, + stubKey([]uint{10}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9002, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 50, UnitPrice: 30, TotalCost: 1500}, + }, + }, + adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ + stubKey([]uint{101, 102}, []string{"PAKAN-CUTOVER"}): { + {AdjustmentID: 8001, ProductID: 11, ProductName: "Pakan Growing Cut-over", Qty: 20, Price: 30, GrandTotal: 600}, + }, + stubKey([]uint{10}, []string{"PAKAN-CUTOVER"}): { + {AdjustmentID: 8002, ProductID: 12, ProductName: "Pakan Laying Cut-over", Qty: 10, Price: 30, GrandTotal: 300}, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{101, 102}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 10: {projectFlockID: 1, totalQty: 250}, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 10: {pieces: 100, kg: 10}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 10: {pieces: 40, kg: 4}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(10, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected breakdown result") + } + if got := result.TotalProductionCost; got != 2950 { + t.Fatalf("expected total production cost 2950, got %v", got) + } + if len(result.Components) != 1 { + t.Fatalf("expected 1 component, got %d", len(result.Components)) + } + component := result.Components[0] + if component.Code != "PAKAN" { + t.Fatalf("expected PAKAN component, got %s", component.Code) + } + partTotals := map[string]float64{} + for _, part := range component.Parts { + partTotals[part.Code] = part.Total + } + if partTotals[hppV2PartGrowingNormal] != 1000 { + t.Fatalf("expected growing normal 1000, got %v", partTotals[hppV2PartGrowingNormal]) + } + if partTotals[hppV2PartGrowingCutover] != 150 { + t.Fatalf("expected growing cutover 150, got %v", partTotals[hppV2PartGrowingCutover]) + } + if partTotals[hppV2PartLayingNormal] != 1500 { + t.Fatalf("expected laying normal 1500, got %v", partTotals[hppV2PartLayingNormal]) + } + if partTotals[hppV2PartLayingCutover] != 300 { + t.Fatalf("expected laying cutover 300, got %v", partTotals[hppV2PartLayingCutover]) + } + if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 { + t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration) + } + if result.Hpp.Estimation.HargaKg != 295 { + t.Fatalf("expected estimation harga/kg 295, got %v", result.Hpp.Estimation.HargaKg) + } + if result.Hpp.Real.HargaKg != 737.5 { + t.Fatalf("expected real harga/kg 737.5, got %v", result.Hpp.Real.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_ManualCutoverUsesLayingSlicesOnly(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 20: { + ProjectFlockKandangID: 20, + ProjectFlockID: 3, + ProjectFlockCategory: "LAYING", + KandangID: 200, + KandangName: "Kandang B", + LocationID: 17, + }, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{20}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9100, SourceProductID: 21, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 10, TotalCost: 200}, + }, + }, + adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ + stubKey([]uint{20}, []string{"PAKAN-CUTOVER"}): { + {AdjustmentID: 8100, ProductID: 22, ProductName: "Pakan Laying Cut-over", Qty: 30, Price: 10, GrandTotal: 300}, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 20: {pieces: 50, kg: 5}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 20: {pieces: 25, kg: 2.5}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(20, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result.TotalProductionCost != 500 { + t.Fatalf("expected total production cost 500, got %v", result.TotalProductionCost) + } + component := result.Components[0] + if len(component.Parts) != 2 { + t.Fatalf("expected 2 laying parts, got %d", len(component.Parts)) + } + for _, part := range component.Parts { + if strings.HasPrefix(part.Code, "growing_") { + t.Fatalf("expected no growing parts, got %s", part.Code) + } + } + if result.Hpp.Estimation.HargaKg != 100 { + t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 30: { + ProjectFlockKandangID: 30, + ProjectFlockID: 4, + ProjectFlockCategory: "LAYING", + KandangID: 300, + KandangName: "Kandang C", + LocationID: 18, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 5: {301, 302}, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{30}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9200, SourceProductID: 31, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 25, TotalCost: 500}, + }, + stubKey([]uint{301, 302}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): { + {StockableType: "purchase_items", StockableID: 9201, SourceProductID: 32, SourceProductName: "OVK Growing", Qty: 40, UnitPrice: 10, TotalCost: 400}, + }, + stubKey([]uint{30}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): { + {StockableType: "purchase_items", StockableID: 9202, SourceProductID: 33, SourceProductName: "OVK Laying", Qty: 15, UnitPrice: 10, TotalCost: 150}, + }, + }, + adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ + stubKey([]uint{301, 302}, []string{"OVK-CUTOVER"}): { + {AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100}, + }, + stubKey([]uint{30}, []string{"OVK-CUTOVER"}): { + {AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50}, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{301, 302}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 30: {projectFlockID: 5, totalQty: 500}, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 30: {pieces: 120, kg: 12}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 30: {pieces: 60, kg: 6}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(30, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected breakdown result") + } + if len(result.Components) != 2 { + t.Fatalf("expected 2 components, got %d", len(result.Components)) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + + if componentTotals[hppV2ComponentPakan] != 500 { + t.Fatalf("expected pakan total 500, got %v", componentTotals[hppV2ComponentPakan]) + } + if componentTotals[hppV2ComponentOvk] != 450 { + t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk]) + } + if result.TotalProductionCost != 950 { + t.Fatalf("expected total production cost 950, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 79.17 { + t.Fatalf("expected estimation harga/kg 79.17, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 40: { + ProjectFlockKandangID: 40, + ProjectFlockID: 6, + ProjectFlockCategory: "LAYING", + KandangID: 400, + KandangName: "Kandang D", + LocationID: 19, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 6: {40, 41}, + 7: {701, 702}, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{701, 702}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 40: {projectFlockID: 7, totalQty: 200}, + }, + expenseRowsByPFKKey: map[string][]commonRepo.HppV2ExpenseCostRow{ + expenseStubKey([]uint{701, 702}, false): { + {ExpenseRealizationID: 1, NonstockID: 11, NonstockName: "Growing BOP", Qty: 1, Price: 500, TotalCost: 500, RealizationDate: mustTime(t, "2026-04-10")}, + }, + expenseStubKey([]uint{40}, false): { + {ExpenseRealizationID: 2, NonstockID: 12, NonstockName: "Laying BOP", Qty: 1, Price: 80, TotalCost: 80, RealizationDate: mustTime(t, "2026-04-19")}, + }, + expenseStubKey([]uint{701, 702}, true): { + {ExpenseRealizationID: 3, NonstockID: 13, NonstockName: "Growing Expedition", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-11")}, + }, + expenseStubKey([]uint{40}, true): { + {ExpenseRealizationID: 4, NonstockID: 14, NonstockName: "Laying Expedition", Qty: 1, Price: 40, TotalCost: 40, RealizationDate: mustTime(t, "2026-04-19")}, + }, + }, + expenseRowsByFarmKey: map[string][]commonRepo.HppV2ExpenseCostRow{ + expenseFarmKey(7, false): { + {ExpenseRealizationID: 5, NonstockID: 15, NonstockName: "Growing Farm BOP", Qty: 1, Price: 300, TotalCost: 300, RealizationDate: mustTime(t, "2026-04-12")}, + }, + expenseFarmKey(6, false): { + {ExpenseRealizationID: 6, NonstockID: 16, NonstockName: "Laying Farm BOP", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-19")}, + }, + expenseFarmKey(7, true): { + {ExpenseRealizationID: 7, NonstockID: 17, NonstockName: "Growing Farm Expedition", Qty: 1, Price: 50, TotalCost: 50, RealizationDate: mustTime(t, "2026-04-12")}, + }, + expenseFarmKey(6, true): { + {ExpenseRealizationID: 8, NonstockID: 18, NonstockName: "Laying Farm Expedition", Qty: 1, Price: 60, TotalCost: 60, RealizationDate: mustTime(t, "2026-04-19")}, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 40: {pieces: 30, kg: 3}, + 41: {pieces: 70, kg: 7}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 40: {pieces: 50, kg: 5}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(40, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + + if componentTotals[hppV2ComponentBopRegular] != 270 { + t.Fatalf("expected regular BOP total 270, got %v", componentTotals[hppV2ComponentBopRegular]) + } + if componentTotals[hppV2ComponentBopEksp] != 88 { + t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp]) + } + if result.TotalProductionCost != 358 { + t.Fatalf("expected total production cost 358, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 119.33 { + t.Fatalf("expected estimation harga/kg 119.33, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func stubKey(ids []uint, flags []string) string { + idParts := make([]string, 0, len(ids)) + for _, id := range ids { + idParts = append(idParts, fmt.Sprintf("%d", id)) + } + sort.Strings(idParts) + + flagParts := append([]string{}, flags...) + sort.Strings(flagParts) + + return strings.Join(idParts, ",") + "|" + strings.Join(flagParts, ",") +} + +func mustDate(t *testing.T, raw string) *time.Time { + t.Helper() + loc, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed to load timezone: %v", err) + } + value, err := time.ParseInLocation("2006-01-02", raw, loc) + if err != nil { + t.Fatalf("failed to parse date %s: %v", raw, err) + } + return &value +} + +func mustTime(t *testing.T, raw string) time.Time { + t.Helper() + value := mustDate(t, raw) + return *value +} + +func expenseStubKey(ids []uint, ekspedisi bool) string { + return stubKey(ids, []string{fmt.Sprintf("ekspedisi=%t", ekspedisi)}) +} + +func expenseFarmKey(projectFlockID uint, ekspedisi bool) string { + return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi) +} diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 181990a1..691cafc0 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -498,6 +498,29 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { }) } +func (c *RepportController) GetHppV2Breakdown(ctx *fiber.Ctx) error { + query := &validation.HppV2BreakdownQuery{ + ProjectFlockKandangID: uint(ctx.QueryInt("project_flock_kandang_id", 0)), + Period: ctx.Query("period", ""), + } + + if err := m.EnsureProjectFlockKandangAccess(ctx, c.RepportService.DB(), 0, query.ProjectFlockKandangID); err != nil { + return err + } + + data, err := c.RepportService.GetHppV2Breakdown(ctx, query) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get HPP v2 breakdown successfully", + Data: data, + }) +} + func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return parseCommaSeparatedInt64sWithField(raw, "supplier_ids") } diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index be6da322..16c14de5 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -23,6 +23,7 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) + route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index f2473721..0a49ed9f 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -49,6 +49,7 @@ type RepportService interface { GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) + GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) DB() *gorm.DB @@ -2146,6 +2147,27 @@ func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Locatio return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc) } +func (s *repportService) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if s.HppV2Svc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "hpp v2 service is not configured") + } + + periodDate, err := time.ParseInLocation("2006-01-02", params.Period, time.FixedZone("Asia/Jakarta", 7*60*60)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD") + } + + result, err := s.HppV2Svc.CalculateHppBreakdown(params.ProjectFlockKandangID, &periodDate) + if err != nil { + return nil, err + } + + return result, nil +} + func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 7130346c..27f1d741 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -75,6 +75,11 @@ type HppPerKandangQuery struct { WeightMax *float64 `query:"-"` } +type HppV2BreakdownQuery struct { + ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"required,gt=0"` + Period string `query:"period" validate:"required,datetime=2006-01-02"` +} + 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"` From a2ae139fae2a6f166082dbd13d7608c0be999c68 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Sun, 19 Apr 2026 14:52:01 +0700 Subject: [PATCH 4/8] feat: doc direct purchase cost --- .../repository/common.hppv2.repository.go | 189 ++++++++++ .../service/common.depreciation.service.go | 88 +++++ .../common.depreciation.service_test.go | 60 ++++ internal/common/service/common.hppv2.model.go | 10 +- .../common/service/common.hppv2.service.go | 332 +++++++++++++++--- .../service/common.hppv2.service_test.go | 83 +++++ .../repports/services/repport.service.go | 64 +--- 7 files changed, 718 insertions(+), 108 deletions(-) create mode 100644 internal/common/service/common.depreciation.service.go create mode 100644 internal/common/service/common.depreciation.service_test.go diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index ccba7120..352ca11e 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -59,6 +59,19 @@ type HppV2ExpenseCostRow struct { RealizationDate time.Time } +type HppV2ChickinCostRow struct { + ProjectChickinID uint + ProjectFlockKandangID uint + ChickInDate time.Time + StockableType string + StockableID uint + SourceProductID uint + SourceProductName string + Qty float64 + UnitPrice float64 + TotalCost float64 +} + type HppV2CostRepository interface { GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) @@ -66,6 +79,7 @@ type HppV2CostRepository interface { ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error) ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) ListExpenseRealizationRowsByProjectFlockID(ctx context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) + ListChickinCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time, excludeTransferToLaying bool) ([]HppV2ChickinCostRow, error) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) @@ -251,6 +265,181 @@ func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags( return rows, nil } +func (r *HppV2RepositoryImpl) ListChickinCostRowsByProductFlags( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, + date *time.Time, + excludeTransferToLaying bool, +) ([]HppV2ChickinCostRow, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return []HppV2ChickinCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + stockableTransferIn := fifo.StockableKeyStockTransferIn.String() + stockableTransferToLaying := fifo.StockableKeyTransferToLayingIn.String() + usableProjectChickin := fifo.UsableKeyProjectChickin.String() + usableStockTransferOut := fifo.UsableKeyStockTransferOut.String() + + rows := make([]HppV2ChickinCostRow, 0) + query := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select(` + pc.id AS project_chickin_id, + pc.project_flock_kandang_id AS project_flock_kandang_id, + pc.chick_in_date AS chick_in_date, + sa.stockable_type AS stockable_type, + sa.stockable_id AS stockable_id, + COALESCE( + pi.product_id, + ast_pw.product_id, + tpi.product_id, + tast_pw.product_id, + spi.product_id, + sast_pw.product_id, + 0 + ) AS source_product_id, + COALESCE( + pi_prod.name, + ast_prod.name, + tpi_prod.name, + tast_prod.name, + spi_prod.name, + sast_prod.name, + '' + ) AS source_product_name, + COALESCE(SUM(sa.qty), 0) AS qty, + CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(spi.price, sast.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, tast.price, 0) + ELSE 0 + END AS unit_price, + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(spi.price, sast.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, tast.price, 0) + ELSE 0 + END), 0) AS total_cost + `, + stockablePurchase, + stockableAdjustment, + stockableTransferIn, + stockableTransferToLaying, + stockablePurchase, + stockableAdjustment, + stockableTransferIn, + stockableTransferToLaying, + ). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", + usableProjectChickin, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeTraceChickin, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS pi_prod ON pi_prod.id = pi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id"). + Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id"). + Joins( + "LEFT JOIN stock_allocations AS tsa_transfer ON tsa_transfer.usable_type = ? AND tsa_transfer.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa_transfer.status = ? AND tsa_transfer.allocation_purpose = ?", + stockableTransferToLaying, + stockableTransferToLaying, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa_transfer.stockable_id AND tsa_transfer.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS tpi_prod ON tpi_prod.id = tpi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS tast ON tast.id = tsa_transfer.stockable_id AND tsa_transfer.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS tast_pw ON tast_pw.id = tast.product_warehouse_id"). + Joins("LEFT JOIN products AS tast_prod ON tast_prod.id = tast_pw.product_id"). + Joins( + "LEFT JOIN stock_allocations AS tsa_stock ON tsa_stock.usable_type = ? AND tsa_stock.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa_stock.status = ? AND tsa_stock.allocation_purpose = ?", + usableStockTransferOut, + stockableTransferIn, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS spi ON spi.id = tsa_stock.stockable_id AND tsa_stock.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS spi_prod ON spi_prod.id = spi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS sast ON sast.id = tsa_stock.stockable_id AND tsa_stock.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS sast_pw ON sast_pw.id = sast.product_warehouse_id"). + Joins("LEFT JOIN products AS sast_prod ON sast_prod.id = sast_pw.product_id"). + Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("pc.chick_in_date <= ?", *date). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = COALESCE( + pi.product_id, + ast_pw.product_id, + tpi.product_id, + tast_pw.product_id, + spi.product_id, + sast_pw.product_id, + 0 + ) + AND f.name IN ? + ) + `, entity.FlagableTypeProduct, flagNames) + + if excludeTransferToLaying { + query = query.Where("sa.stockable_type <> ?", stockableTransferToLaying) + } + + err := query. + Group(` + pc.id, + pc.project_flock_kandang_id, + pc.chick_in_date, + sa.stockable_type, + sa.stockable_id, + COALESCE( + pi.product_id, + ast_pw.product_id, + tpi.product_id, + tast_pw.product_id, + spi.product_id, + sast_pw.product_id, + 0 + ), + COALESCE( + pi_prod.name, + ast_prod.name, + tpi_prod.name, + tast_prod.name, + spi_prod.name, + sast_prod.name, + '' + ), + CASE + WHEN sa.stockable_type = '` + stockablePurchase + `' THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = '` + stockableAdjustment + `' THEN COALESCE(ast.price, 0) + WHEN sa.stockable_type = '` + stockableTransferIn + `' THEN COALESCE(spi.price, sast.price, 0) + WHEN sa.stockable_type = '` + stockableTransferToLaying + `' THEN COALESCE(tpi.price, tast.price, 0) + ELSE 0 + END + `). + Order("pc.chick_in_date ASC, pc.id ASC, sa.stockable_type ASC, sa.stockable_id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + func (r *HppV2RepositoryImpl) ListExpenseRealizationRowsByProjectFlockKandangIDs( ctx context.Context, projectFlockKandangIDs []uint, diff --git a/internal/common/service/common.depreciation.service.go b/internal/common/service/common.depreciation.service.go new file mode 100644 index 00000000..b0cd9497 --- /dev/null +++ b/internal/common/service/common.depreciation.service.go @@ -0,0 +1,88 @@ +package service + +import ( + "strings" + "time" +) + +const ( + depreciationStartAgeDayCloseHouse = 155 + depreciationStartAgeDayOpenHouse = 176 +) + +func NormalizeDepreciationHouseType(raw string) string { + return strings.TrimSpace(strings.ToLower(raw)) +} + +func DepreciationStartAgeDay(houseType string) int { + switch NormalizeDepreciationHouseType(houseType) { + case "close_house": + return depreciationStartAgeDayCloseHouse + case "open_house": + return depreciationStartAgeDayOpenHouse + default: + return 0 + } +} + +func FlockAgeDay(originDate time.Time, periodDate time.Time) int { + origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, originDate.Location()) + period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location()) + if period.Before(origin) { + return 0 + } + return int(period.Sub(origin).Hours()/24) + 1 +} + +func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int { + ageDay := FlockAgeDay(originDate, periodDate) + startAgeDay := DepreciationStartAgeDay(houseType) + if ageDay <= 0 || startAgeDay <= 0 || ageDay < startAgeDay { + return 0 + } + return ageDay - startAgeDay + 1 +} + +func CalculateDepreciationAtDayN( + initialPulletCost float64, + dayN int, + houseType string, + percentByHouseType map[string]map[int]float64, +) (float64, float64, float64) { + if initialPulletCost <= 0 || dayN <= 0 { + return 0, 0, 0 + } + + normalizedHouseType := NormalizeDepreciationHouseType(houseType) + housePercent, exists := percentByHouseType[normalizedHouseType] + if !exists { + return 0, 0, 0 + } + + current := initialPulletCost + pulletCostDayN := 0.0 + depreciationValue := 0.0 + depreciationPercent := 0.0 + for day := 1; day <= dayN; day++ { + pct := housePercent[day] + dep := current * (pct / 100) + if day == dayN { + pulletCostDayN = current + depreciationValue = dep + depreciationPercent = pct + } + current -= dep + if current < 0 { + current = 0 + } + } + + return pulletCostDayN, depreciationValue, depreciationPercent +} + +func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 { + if totalPulletCostDayN <= 0 { + return 0 + } + return (totalDepreciationValue / totalPulletCostDayN) * 100 +} diff --git a/internal/common/service/common.depreciation.service_test.go b/internal/common/service/common.depreciation.service_test.go new file mode 100644 index 00000000..c05935e6 --- /dev/null +++ b/internal/common/service/common.depreciation.service_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "testing" + "time" +) + +func TestDepreciationScheduleDay_UsesHouseTypeOffsets(t *testing.T) { + openOrigin := mustDepreciationDate(t, "2026-01-01") + if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-24"), "open_house"); got != 0 { + t.Fatalf("expected open house day before start to be 0, got %d", got) + } + if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-25"), "open_house"); got != 1 { + t.Fatalf("expected open house start day to map to schedule day 1, got %d", got) + } + + closeOrigin := mustDepreciationDate(t, "2026-01-01") + if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-03"), "close_house"); got != 0 { + t.Fatalf("expected close house day before start to be 0, got %d", got) + } + if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-04"), "close_house"); got != 1 { + t.Fatalf("expected close house start day to map to schedule day 1, got %d", got) + } +} + +func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T) { + percentByHouseType := map[string]map[int]float64{ + "close_house": { + 1: 10, + 2: 20, + }, + } + + pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN(1000, 2, "close_house", percentByHouseType) + if pulletCostDayN != 900 { + t.Fatalf("expected remaining basis entering day 2 to be 900, got %v", pulletCostDayN) + } + if depreciationValue != 180 { + t.Fatalf("expected day 2 depreciation to be 180, got %v", depreciationValue) + } + if depreciationPercent != 20 { + t.Fatalf("expected day 2 depreciation percent to be 20, got %v", depreciationPercent) + } +} + +func mustDepreciationDate(t *testing.T, raw string) time.Time { + t.Helper() + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading timezone: %v", err) + } + + value, err := time.ParseInLocation("2006-01-02", raw, location) + if err != nil { + t.Fatalf("failed parsing date %q: %v", raw, err) + } + + return value +} diff --git a/internal/common/service/common.hppv2.model.go b/internal/common/service/common.hppv2.model.go index 134ecb81..82c36ef0 100644 --- a/internal/common/service/common.hppv2.model.go +++ b/internal/common/service/common.hppv2.model.go @@ -35,10 +35,11 @@ type HppV2ComponentPart struct { } type HppV2Component struct { - Code string `json:"code"` - Title string `json:"title"` - Total float64 `json:"total"` - Parts []HppV2ComponentPart `json:"parts"` + Code string `json:"code"` + Title string `json:"title"` + Scopes []string `json:"scopes,omitempty"` + Total float64 `json:"total"` + Parts []HppV2ComponentPart `json:"parts"` } type HppV2Breakdown struct { @@ -50,6 +51,7 @@ type HppV2Breakdown struct { LocationID uint `json:"location_id,omitempty"` PeriodDate string `json:"period_date"` Window HppV2DateWindow `json:"window"` + TotalPulletCost float64 `json:"total_pullet_cost"` TotalProductionCost float64 `json:"total_production_cost"` Components []HppV2Component `json:"components"` Hpp HppCostResponse `json:"hpp"` diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index 0bde1dfa..3c753c55 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -9,23 +9,27 @@ import ( ) const ( - hppV2ComponentPakan = "PAKAN" - hppV2ComponentOvk = "OVK" - hppV2ComponentBopRegular = "BOP_REGULAR" - hppV2ComponentBopEksp = "BOP_EKSPEDISI" - hppV2PartGrowingNormal = "growing_normal" - hppV2PartGrowingCutover = "growing_cutover" - hppV2PartLayingNormal = "laying_normal" - hppV2PartLayingCutover = "laying_cutover" - hppV2PartGrowingDirect = "growing_direct" - hppV2PartGrowingFarm = "growing_farm" - hppV2PartLayingDirect = "laying_direct" - hppV2PartLayingFarm = "laying_farm" - hppV2ProrationPopulation = "growing_population_share" - hppV2ProrationEggWeight = "laying_egg_weight_share" - hppV2ProrationEggPiece = "laying_egg_piece_share" - hppV2CutoverFlagPakan = "PAKAN-CUTOVER" - hppV2CutoverFlagOvk = "OVK-CUTOVER" + hppV2ComponentPakan = "PAKAN" + hppV2ComponentOvk = "OVK" + hppV2ComponentDocChickin = "DOC_CHICKIN" + hppV2ComponentDirectPulletPurchase = "DIRECT_PULLET_PURCHASE" + hppV2ComponentBopRegular = "BOP_REGULAR" + hppV2ComponentBopEksp = "BOP_EKSPEDISI" + hppV2PartGrowingNormal = "growing_normal" + hppV2PartGrowingCutover = "growing_cutover" + hppV2PartLayingNormal = "laying_normal" + hppV2PartLayingCutover = "laying_cutover" + hppV2PartGrowingDirect = "growing_direct" + hppV2PartGrowingFarm = "growing_farm" + hppV2PartLayingDirect = "laying_direct" + hppV2PartLayingFarm = "laying_farm" + hppV2ProrationPopulation = "growing_population_share" + hppV2ProrationEggWeight = "laying_egg_weight_share" + hppV2ProrationEggPiece = "laying_egg_piece_share" + hppV2ScopePulletCost = "pullet_cost" + hppV2ScopeProductionCost = "production_cost" + hppV2CutoverFlagPakan = "PAKAN-CUTOVER" + hppV2CutoverFlagOvk = "OVK-CUTOVER" ) type HppV2Service interface { @@ -33,10 +37,14 @@ type HppV2Service interface { CalculateHppBreakdown(projectFlockKandangId uint, date *time.Time) (*HppV2Breakdown, error) GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostDocChickin(projectFlockKandangId uint, endDate *time.Time) (float64, error) + GetCostDirectPulletPurchase(projectFlockKandangId uint, endDate *time.Time) (float64, error) GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error) GetCostBopEkspedisi(projectFlockKandangId uint, endDate *time.Time) (float64, error) GetPakanBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) GetOvkBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetDocChickinBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) + GetDirectPulletPurchaseBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) @@ -99,39 +107,52 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t return nil, err } + totalPulletCost := 0.0 totalProductionCost := 0.0 - components := make([]HppV2Component, 0, 4) - if pakanComponent != nil && (pakanComponent.Total > 0 || len(pakanComponent.Parts) > 0) { - totalProductionCost += pakanComponent.Total - components = append(components, *pakanComponent) + components := make([]HppV2Component, 0, 6) + appendComponent := func(component *HppV2Component) { + if component == nil || (component.Total == 0 && len(component.Parts) == 0) { + return + } + components = append(components, *component) + if componentHasScope(component, hppV2ScopePulletCost) { + totalPulletCost += component.Total + } + if componentHasScope(component, hppV2ScopeProductionCost) { + totalProductionCost += component.Total + } } + appendComponent(pakanComponent) ovkComponent, err := s.GetOvkBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - if ovkComponent != nil && (ovkComponent.Total > 0 || len(ovkComponent.Parts) > 0) { - totalProductionCost += ovkComponent.Total - components = append(components, *ovkComponent) + appendComponent(ovkComponent) + + docComponent, err := s.GetDocChickinBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err } + appendComponent(docComponent) + + directPulletComponent, err := s.GetDirectPulletPurchaseBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + appendComponent(directPulletComponent) bopRegularComponent, err := s.GetBopRegularBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - if bopRegularComponent != nil && (bopRegularComponent.Total > 0 || len(bopRegularComponent.Parts) > 0) { - totalProductionCost += bopRegularComponent.Total - components = append(components, *bopRegularComponent) - } + appendComponent(bopRegularComponent) bopEkspedisiComponent, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - if bopEkspedisiComponent != nil && (bopEkspedisiComponent.Total > 0 || len(bopEkspedisiComponent.Parts) > 0) { - totalProductionCost += bopEkspedisiComponent.Total - components = append(components, *bopEkspedisiComponent) - } + appendComponent(bopEkspedisiComponent) hppCost, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) if err != nil { @@ -153,6 +174,7 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t Start: startOfDay.Format(time.RFC3339), End: endOfDay.Format(time.RFC3339), }, + TotalPulletCost: totalPulletCost, TotalProductionCost: totalProductionCost, Components: components, Hpp: *hppCost, @@ -206,6 +228,88 @@ func (s *hppV2Service) GetOvkBreakdown(projectFlockKandangId uint, endDate *time }) } +func (s *hppV2Service) GetCostDocChickin(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetDocChickinBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetCostDirectPulletPurchase(projectFlockKandangId uint, endDate *time.Time) (float64, error) { + component, err := s.GetDirectPulletPurchaseBreakdown(projectFlockKandangId, endDate) + if err != nil { + return 0, err + } + if component == nil { + return 0, nil + } + + return component.Total, nil +} + +func (s *hppV2Service) GetDocChickinBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + if s.hppRepo == nil { + return &HppV2Component{ + Code: hppV2ComponentDocChickin, + Title: "DOC Chick-in", + Scopes: []string{hppV2ScopePulletCost}, + Parts: []HppV2ComponentPart{}, + }, nil + } + + contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + part, err := s.buildGrowingChickinPart(projectFlockKandangId, contextRow, endDate, []string{string(utils.FlagDOC)}, false, hppV2PartGrowingDirect, "Growing DOC") + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 1) + total := 0.0 + if part != nil { + parts = append(parts, *part) + total += part.Total + } + + return &HppV2Component{ + Code: hppV2ComponentDocChickin, + Title: "DOC Chick-in", + Scopes: []string{hppV2ScopePulletCost}, + Total: total, + Parts: parts, + }, nil +} + +func (s *hppV2Service) GetDirectPulletPurchaseBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) { + part, err := s.buildLayingChickinPart(projectFlockKandangId, endDate, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true, hppV2PartLayingDirect, "Laying Direct Pullet") + if err != nil { + return nil, err + } + + parts := make([]HppV2ComponentPart, 0, 1) + total := 0.0 + if part != nil { + parts = append(parts, *part) + total += part.Total + } + + return &HppV2Component{ + Code: hppV2ComponentDirectPulletPurchase, + Title: "Direct Pullet Purchase", + Scopes: []string{hppV2ScopeProductionCost}, + Total: total, + Parts: parts, + }, nil +} + func (s *hppV2Service) GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error) { component, err := s.GetBopRegularBreakdown(projectFlockKandangId, endDate) if err != nil { @@ -249,9 +353,10 @@ func (s *hppV2Service) GetBopEkspedisiBreakdown(projectFlockKandangId uint, endD func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2StockComponentConfig) (*HppV2Component, error) { if s.hppRepo == nil { return &HppV2Component{ - Code: config.Code, - Title: config.Title, - Parts: []HppV2ComponentPart{}, + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Parts: []HppV2ComponentPart{}, }, nil } @@ -300,19 +405,21 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat } return &HppV2Component{ - Code: config.Code, - Title: config.Title, - Total: total, - Parts: parts, + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Total: total, + Parts: parts, }, nil } func (s *hppV2Service) getExpenseComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2ExpenseComponentConfig) (*HppV2Component, error) { if s.hppRepo == nil { return &HppV2Component{ - Code: config.Code, - Title: config.Title, - Parts: []HppV2ComponentPart{}, + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Parts: []HppV2ComponentPart{}, }, nil } @@ -361,13 +468,91 @@ func (s *hppV2Service) getExpenseComponent(projectFlockKandangId uint, endDate * } return &HppV2Component{ - Code: config.Code, - Title: config.Title, - Total: total, - Parts: parts, + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Total: total, + Parts: parts, }, nil } +func (s *hppV2Service) buildGrowingChickinPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + endDate *time.Time, + flagNames []string, + excludeTransferToLaying bool, + partCode string, + partTitle string, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + if sourceProjectFlockID == 0 || transferTotalQty <= 0 { + return nil, nil + } + + kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) + if err != nil { + return nil, err + } + if len(kandangIDsGrowing) == 0 { + return nil, nil + } + + totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) + if err != nil { + return nil, err + } + if totalPopulationFlockGrowing <= 0 { + return nil, nil + } + + ratio := transferTotalQty / totalPopulationFlockGrowing + if ratio <= 0 { + return nil, nil + } + + rows, err := s.hppRepo.ListChickinCostRowsByProductFlags(context.Background(), kandangIDsGrowing, flagNames, endDate, excludeTransferToLaying) + if err != nil { + return nil, err + } + + return buildChickinPartFromRows( + rows, + partCode, + partTitle, + &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: transferTotalQty, + Denominator: totalPopulationFlockGrowing, + Ratio: ratio, + }, + ratio, + ), nil +} + +func (s *hppV2Service) buildLayingChickinPart( + projectFlockKandangId uint, + endDate *time.Time, + flagNames []string, + excludeTransferToLaying bool, + partCode string, + partTitle string, +) (*HppV2ComponentPart, error) { + rows, err := s.hppRepo.ListChickinCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, flagNames, endDate, excludeTransferToLaying) + if err != nil { + return nil, err + } + + return buildChickinPartFromRows(rows, partCode, partTitle, nil, 1), nil +} + func (s *hppV2Service) buildGrowingUsagePart( projectFlockKandangId uint, contextRow *commonRepo.HppV2ProjectFlockKandangContext, @@ -825,3 +1010,58 @@ func buildExpensePartFromRows( References: references, } } + +func buildChickinPartFromRows( + rows []commonRepo.HppV2ChickinCostRow, + code string, + title string, + proration *HppV2Proration, + ratio float64, +) *HppV2ComponentPart { + if len(rows) == 0 { + return nil + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + total += row.TotalCost * ratio + projectFlockKandangID := row.ProjectFlockKandangID + references = append(references, HppV2Reference{ + Type: "project_chickin", + ID: row.ProjectChickinID, + StockableType: row.StockableType, + ProjectFlockKandangID: &projectFlockKandangID, + ProductID: row.SourceProductID, + ProductName: row.SourceProductName, + Date: row.ChickInDate.Format("2006-01-02"), + Qty: row.Qty, + UnitPrice: row.UnitPrice, + Total: row.TotalCost, + AppliedTotal: row.TotalCost * ratio, + }) + } + if total == 0 { + return nil + } + + return &HppV2ComponentPart{ + Code: code, + Title: title, + Total: total, + Proration: proration, + References: references, + } +} + +func componentHasScope(component *HppV2Component, scope string) bool { + if component == nil || scope == "" { + return false + } + for _, candidate := range component.Scopes { + if candidate == scope { + return true + } + } + return false +} diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go index 1a7aa2a1..f3dd0747 100644 --- a/internal/common/service/common.hppv2.service_test.go +++ b/internal/common/service/common.hppv2.service_test.go @@ -17,6 +17,7 @@ type hppV2RepoStub struct { pfkIDsByProject map[uint][]uint usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow + chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow totalPopulationByKey map[string]float64 @@ -62,6 +63,10 @@ func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Con return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil } +func (s *hppV2RepoStub) ListChickinCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time, excludeTransferToLaying bool) ([]commonRepo.HppV2ChickinCostRow, error) { + return append([]commonRepo.HppV2ChickinCostRow{}, s.chickinRowsByKey[chickinStubKey(projectFlockKandangIDs, flagNames, excludeTransferToLaying)]...), nil +} + func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) { return 0, nil } @@ -339,6 +344,80 @@ func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) { } } +func TestHppV2CalculateHppBreakdown_IncludesDocAndDirectPulletChickin(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 35: { + ProjectFlockKandangID: 35, + ProjectFlockID: 8, + ProjectFlockCategory: "LAYING", + KandangID: 350, + KandangName: "Kandang E", + LocationID: 20, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 9: {901, 902}, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{901, 902}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 35: {projectFlockID: 9, totalQty: 250}, + }, + chickinRowsByKey: map[string][]commonRepo.HppV2ChickinCostRow{ + chickinStubKey([]uint{901, 902}, []string{string(utils.FlagDOC)}, false): { + {ProjectChickinID: 1, ProjectFlockKandangID: 901, ChickInDate: mustTime(t, "2026-04-01"), StockableType: "purchase_items", StockableID: 1001, SourceProductID: 77, SourceProductName: "DOC", Qty: 1000, UnitPrice: 2, TotalCost: 2000}, + }, + chickinStubKey([]uint{35}, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true): { + {ProjectChickinID: 2, ProjectFlockKandangID: 35, ChickInDate: mustTime(t, "2026-04-15"), StockableType: "purchase_items", StockableID: 1002, SourceProductID: 78, SourceProductName: "Pullet", Qty: 50, UnitPrice: 20, TotalCost: 1000}, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 35: {pieces: 100, kg: 10}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 35: {pieces: 80, kg: 8}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(35, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + + if componentTotals[hppV2ComponentDocChickin] != 500 { + t.Fatalf("expected doc chickin total 500, got %v", componentTotals[hppV2ComponentDocChickin]) + } + if componentTotals[hppV2ComponentDirectPulletPurchase] != 1000 { + t.Fatalf("expected direct pullet purchase total 1000, got %v", componentTotals[hppV2ComponentDirectPulletPurchase]) + } + if result.TotalPulletCost != 500 { + t.Fatalf("expected total pullet cost 500, got %v", result.TotalPulletCost) + } + if result.TotalProductionCost != 1000 { + t.Fatalf("expected total production cost 1000, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 100 { + t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg) + } +} + func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) { repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ @@ -471,3 +550,7 @@ func expenseStubKey(ids []uint, ekspedisi bool) string { func expenseFarmKey(projectFlockID uint, ekspedisi bool) string { return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi) } + +func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string { + return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying))) +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 0a49ed9f..87a0605a 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -473,11 +473,11 @@ func (s *repportService) computeExpenseDepreciationSnapshots( for _, row := range inputRows { groupedByFarm[row.ProjectFlockID] = append(groupedByFarm[row.ProjectFlockID], row) - dayN := depreciationDayNumber(row.TransferDate, periodDate) + dayN := approvalService.DepreciationScheduleDay(row.TransferDate, periodDate, valueOrEmptyString(row.HouseType)) if dayN > maxDay { maxDay = dayN } - houseType := strings.TrimSpace(strings.ToLower(valueOrEmptyString(row.HouseType))) + houseType := approvalService.NormalizeDepreciationHouseType(valueOrEmptyString(row.HouseType)) if houseType != "" { houseTypeSet[houseType] = struct{}{} } @@ -511,8 +511,8 @@ func (s *repportService) computeExpenseDepreciationSnapshots( totalDepreciationValue := 0.0 totalPulletCostDayN := 0.0 for _, row := range farmRows { - dayN := depreciationDayNumber(row.TransferDate, periodDate) - houseType := strings.TrimSpace(strings.ToLower(valueOrEmptyString(row.HouseType))) + dayN := approvalService.DepreciationScheduleDay(row.TransferDate, periodDate, valueOrEmptyString(row.HouseType)) + houseType := approvalService.NormalizeDepreciationHouseType(valueOrEmptyString(row.HouseType)) transferDateKey := row.TransferDate.Format("2006-01-02") cacheKey := fmt.Sprintf("%d|%s", row.SourceProjectFlockID, transferDateKey) @@ -550,7 +550,7 @@ func (s *repportService) computeExpenseDepreciationSnapshots( initialPulletCost = (cached.totalDepCost * row.TransferQty) / sourcePopulation } - pulletCostDayN, depreciationValue, depreciationPercent := calculateDepreciationAtDayN( + pulletCostDayN, depreciationValue, depreciationPercent := approvalService.CalculateDepreciationAtDayN( initialPulletCost, dayN, houseType, @@ -576,8 +576,7 @@ func (s *repportService) computeExpenseDepreciationSnapshots( }) } - effectivePercent := 0.0 - effectivePercent = calculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN) + effectivePercent := approvalService.CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN) componentsJSON, marshalErr := json.Marshal(components) if marshalErr != nil { @@ -597,57 +596,6 @@ func (s *repportService) computeExpenseDepreciationSnapshots( return result, nil } -func depreciationDayNumber(transferDate time.Time, periodDate time.Time) int { - transfer := time.Date(transferDate.Year(), transferDate.Month(), transferDate.Day(), 0, 0, 0, 0, transferDate.Location()) - period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location()) - if period.Before(transfer) { - return 0 - } - return int(period.Sub(transfer).Hours()/24) + 1 -} - -func calculateDepreciationAtDayN( - initialPulletCost float64, - dayN int, - houseType string, - percentByHouseType map[string]map[int]float64, -) (float64, float64, float64) { - if initialPulletCost <= 0 || dayN <= 0 || houseType == "" { - return 0, 0, 0 - } - - housePercent, exists := percentByHouseType[houseType] - if !exists { - return 0, 0, 0 - } - - current := initialPulletCost - pulletCostDayN := 0.0 - depreciationValue := 0.0 - depreciationPercent := 0.0 - for day := 1; day <= dayN; day++ { - pct := housePercent[day] - dep := current * (pct / 100) - if day == dayN { - pulletCostDayN = current - depreciationValue = dep - depreciationPercent = pct - } - current -= dep - if current < 0 { - current = 0 - } - } - return pulletCostDayN, depreciationValue, depreciationPercent -} - -func calculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 { - if totalPulletCostDayN <= 0 { - return 0 - } - return (totalDepreciationValue / totalPulletCostDayN) * 100 -} - func parseSnapshotComponents(raw []byte) any { if len(raw) == 0 { return map[string]any{} From 69d6fc165a839bb82b933d639717e9eabf5b17c1 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Sun, 19 Apr 2026 15:10:53 +0700 Subject: [PATCH 5/8] feat: manual pullet cost --- .../repository/common.hppv2.repository.go | 164 ++++++++ .../service/common.depreciation.service.go | 22 +- .../common.depreciation.service_test.go | 21 + internal/common/service/common.hppv2.model.go | 2 + .../common/service/common.hppv2.service.go | 380 +++++++++++++++++- .../service/common.hppv2.service_test.go | 260 +++++++++++- ...o_farm_depreciation_manual_inputs.down.sql | 4 + ..._to_farm_depreciation_manual_inputs.up.sql | 12 + .../farm_depreciation_manual_input.go | 1 + .../dto/repportExpenseDepreciation.dto.go | 1 + .../expense_depreciation.repository.go | 11 +- .../repports/services/repport.service.go | 11 + .../validations/repport.validation.go | 1 + 13 files changed, 857 insertions(+), 33 deletions(-) create mode 100644 internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql create mode 100644 internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index 352ca11e..80fe7438 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "time" @@ -72,9 +73,29 @@ type HppV2ChickinCostRow struct { TotalCost float64 } +type HppV2LatestTransferInputRow struct { + ProjectFlockKandangID uint + SourceProjectFlockID uint + TransferDate time.Time + TransferQty float64 + TransferID uint +} + +type HppV2ManualDepreciationInputRow struct { + ID uint + ProjectFlockID uint + TotalCost float64 + CutoverDate time.Time + Note *string +} + type HppV2CostRepository interface { GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) + GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error) + GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error) + GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) + GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error) ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error) ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) @@ -136,6 +157,149 @@ func (r *HppV2RepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, pro return ids, nil } +func (r *HppV2RepositoryImpl) GetLatestTransferInputByProjectFlockKandangID( + ctx context.Context, + projectFlockKandangId uint, + period time.Time, +) (*HppV2LatestTransferInputRow, error) { + var row HppV2LatestTransferInputRow + query := ` +WITH latest_transfer_approval AS ( + SELECT a.approvable_id, a.action + FROM approvals a + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = @approval_type + GROUP BY approvable_id + ) la + ON la.approvable_id = a.approvable_id + AND la.latest_action_at = a.action_at + WHERE a.approvable_type = @approval_type +), +approved_transfers AS ( + SELECT + lt.id, + lt.from_project_flock_id, + COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) AS effective_date + FROM laying_transfers lt + JOIN latest_transfer_approval lta ON lta.approvable_id = lt.id + WHERE lt.deleted_at IS NULL + AND lt.executed_at IS NOT NULL + AND lta.action = 'APPROVED' +) +SELECT + ltt.target_project_flock_kandang_id AS project_flock_kandang_id, + at.from_project_flock_id AS source_project_flock_id, + at.effective_date AS transfer_date, + ltt.total_qty AS transfer_qty, + at.id AS transfer_id +FROM laying_transfer_targets ltt +JOIN approved_transfers at ON at.id = ltt.laying_transfer_id +WHERE ltt.deleted_at IS NULL + AND ltt.target_project_flock_kandang_id = @project_flock_kandang_id + AND at.effective_date <= DATE(@period_date) +ORDER BY at.effective_date DESC, at.id DESC +LIMIT 1 +` + + err := r.db.WithContext(ctx).Raw(query, map[string]any{ + "approval_type": utils.ApprovalWorkflowTransferToLaying.String(), + "project_flock_kandang_id": projectFlockKandangId, + "period_date": period, + }).Scan(&row).Error + if err != nil { + return nil, err + } + if row.TransferID == 0 { + return nil, nil + } + + return &row, nil +} + +func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID( + ctx context.Context, + projectFlockID uint, +) (*HppV2ManualDepreciationInputRow, error) { + var row HppV2ManualDepreciationInputRow + err := r.db.WithContext(ctx). + Table("farm_depreciation_manual_inputs"). + Select("id, project_flock_id, total_cost, cutover_date, note"). + Where("project_flock_id = ?", projectFlockID). + Limit(1). + Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + + return &row, nil +} + +func (r *HppV2RepositoryImpl) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) { + type row struct { + ChickInDate *time.Time + } + + var selected row + err := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select("MIN(pc.chick_in_date) AS chick_in_date"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Where("pc.deleted_at IS NULL"). + Where("pfk.project_flock_id = ?", projectFlockID). + Scan(&selected).Error + if err != nil { + return nil, err + } + if selected.ChickInDate == nil || selected.ChickInDate.IsZero() { + return nil, nil + } + + return selected.ChickInDate, nil +} + +func (r *HppV2RepositoryImpl) GetDepreciationPercents( + ctx context.Context, + houseTypes []string, + maxDay int, +) (map[string]map[int]float64, error) { + result := make(map[string]map[int]float64) + if len(houseTypes) == 0 || maxDay <= 0 { + return result, nil + } + + type row struct { + HouseType string + Day int + DepreciationPercent float64 + } + + rows := make([]row, 0) + err := r.db.WithContext(ctx). + Table("house_depreciation_standards"). + Select("house_type::text AS house_type, day, depreciation_percent"). + Where("house_type::text IN ?", houseTypes). + Where("day <= ?", maxDay). + Order("house_type ASC, day ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, item := range rows { + if _, exists := result[item.HouseType]; !exists { + result[item.HouseType] = make(map[int]float64) + } + result[item.HouseType][item.Day] = item.DepreciationPercent + } + + return result, nil +} + func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags( ctx context.Context, projectFlockKandangIDs []uint, diff --git a/internal/common/service/common.depreciation.service.go b/internal/common/service/common.depreciation.service.go index b0cd9497..6f12e077 100644 --- a/internal/common/service/common.depreciation.service.go +++ b/internal/common/service/common.depreciation.service.go @@ -49,7 +49,23 @@ func CalculateDepreciationAtDayN( houseType string, percentByHouseType map[string]map[int]float64, ) (float64, float64, float64) { - if initialPulletCost <= 0 || dayN <= 0 { + return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType) +} + +func CalculateDepreciationFromDayRange( + initialPulletCost float64, + startDay int, + endDay int, + houseType string, + percentByHouseType map[string]map[int]float64, +) (float64, float64, float64) { + if initialPulletCost <= 0 || endDay <= 0 { + return 0, 0, 0 + } + if startDay <= 0 { + startDay = 1 + } + if endDay < startDay { return 0, 0, 0 } @@ -63,10 +79,10 @@ func CalculateDepreciationAtDayN( pulletCostDayN := 0.0 depreciationValue := 0.0 depreciationPercent := 0.0 - for day := 1; day <= dayN; day++ { + for day := startDay; day <= endDay; day++ { pct := housePercent[day] dep := current * (pct / 100) - if day == dayN { + if day == endDay { pulletCostDayN = current depreciationValue = dep depreciationPercent = pct diff --git a/internal/common/service/common.depreciation.service_test.go b/internal/common/service/common.depreciation.service_test.go index c05935e6..6897f926 100644 --- a/internal/common/service/common.depreciation.service_test.go +++ b/internal/common/service/common.depreciation.service_test.go @@ -43,6 +43,27 @@ func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T) } } +func TestCalculateDepreciationFromDayRange_StartsFromProvidedScheduleDay(t *testing.T) { + percentByHouseType := map[string]map[int]float64{ + "close_house": { + 1: 10, + 2: 20, + 3: 5, + }, + } + + pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange(1000, 2, 3, "close_house", percentByHouseType) + if pulletCostDayN != 800 { + t.Fatalf("expected remaining basis entering day 3 to be 800, got %v", pulletCostDayN) + } + if depreciationValue != 40 { + t.Fatalf("expected day 3 depreciation to be 40, got %v", depreciationValue) + } + if depreciationPercent != 5 { + t.Fatalf("expected day 3 depreciation percent to be 5, got %v", depreciationPercent) + } +} + func mustDepreciationDate(t *testing.T, raw string) time.Time { t.Helper() diff --git a/internal/common/service/common.hppv2.model.go b/internal/common/service/common.hppv2.model.go index 82c36ef0..faf7cb33 100644 --- a/internal/common/service/common.hppv2.model.go +++ b/internal/common/service/common.hppv2.model.go @@ -29,8 +29,10 @@ type HppV2Reference struct { type HppV2ComponentPart struct { Code string `json:"code"` Title string `json:"title"` + Scopes []string `json:"scopes,omitempty"` Total float64 `json:"total"` Proration *HppV2Proration `json:"proration,omitempty"` + Details map[string]any `json:"details,omitempty"` References []HppV2Reference `json:"references,omitempty"` } diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index 3c753c55..bebbc9b3 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -15,6 +15,8 @@ const ( hppV2ComponentDirectPulletPurchase = "DIRECT_PULLET_PURCHASE" hppV2ComponentBopRegular = "BOP_REGULAR" hppV2ComponentBopEksp = "BOP_EKSPEDISI" + hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST" + hppV2ComponentDepreciation = "DEPRECIATION" hppV2PartGrowingNormal = "growing_normal" hppV2PartGrowingCutover = "growing_cutover" hppV2PartLayingNormal = "laying_normal" @@ -23,6 +25,9 @@ const ( hppV2PartGrowingFarm = "growing_farm" hppV2PartLayingDirect = "laying_direct" hppV2PartLayingFarm = "laying_farm" + hppV2PartManualCutover = "manual_cutover" + hppV2PartDepreciationNormal = "normal_transfer" + hppV2PartDepreciationCutover = "manual_cutover" hppV2ProrationPopulation = "growing_population_share" hppV2ProrationEggWeight = "laying_egg_weight_share" hppV2ProrationEggPiece = "laying_egg_piece_share" @@ -109,18 +114,14 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t totalPulletCost := 0.0 totalProductionCost := 0.0 - components := make([]HppV2Component, 0, 6) + components := make([]HppV2Component, 0, 8) appendComponent := func(component *HppV2Component) { if component == nil || (component.Total == 0 && len(component.Parts) == 0) { return } components = append(components, *component) - if componentHasScope(component, hppV2ScopePulletCost) { - totalPulletCost += component.Total - } - if componentHasScope(component, hppV2ScopeProductionCost) { - totalProductionCost += component.Total - } + totalPulletCost += componentScopeTotal(component, hppV2ScopePulletCost) + totalProductionCost += componentScopeTotal(component, hppV2ScopeProductionCost) } appendComponent(pakanComponent) @@ -154,6 +155,18 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t } appendComponent(bopEkspedisiComponent) + manualPulletComponent, err := s.getManualPulletCostComponent(projectFlockKandangId, contextRow, startOfDay) + if err != nil { + return nil, err + } + appendComponent(manualPulletComponent) + + depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, totalPulletCost) + if err != nil { + return nil, err + } + appendComponent(depreciationComponent) + hppCost, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) if err != nil { return nil, err @@ -527,6 +540,7 @@ func (s *hppV2Service) buildGrowingChickinPart( rows, partCode, partTitle, + []string{hppV2ScopePulletCost}, &HppV2Proration{ Basis: hppV2ProrationPopulation, Numerator: transferTotalQty, @@ -550,7 +564,7 @@ func (s *hppV2Service) buildLayingChickinPart( return nil, err } - return buildChickinPartFromRows(rows, partCode, partTitle, nil, 1), nil + return buildChickinPartFromRows(rows, partCode, partTitle, []string{hppV2ScopeProductionCost}, nil, 1), nil } func (s *hppV2Service) buildGrowingUsagePart( @@ -653,9 +667,10 @@ func (s *hppV2Service) buildGrowingUsagePart( } return &HppV2ComponentPart{ - Code: partCode, - Title: partTitle, - Total: baseTotal * ratio, + Code: partCode, + Title: partTitle, + Scopes: []string{hppV2ScopePulletCost}, + Total: baseTotal * ratio, Proration: &HppV2Proration{ Basis: hppV2ProrationPopulation, Numerator: transferTotalQty, @@ -703,6 +718,7 @@ func (s *hppV2Service) buildLayingUsagePart( return &HppV2ComponentPart{ Code: hppV2PartLayingCutover, Title: "Laying Cut-over", + Scopes: []string{hppV2ScopeProductionCost}, Total: total, References: references, }, nil @@ -741,6 +757,7 @@ func (s *hppV2Service) buildLayingUsagePart( return &HppV2ComponentPart{ Code: hppV2PartLayingNormal, Title: "Laying", + Scopes: []string{hppV2ScopeProductionCost}, Total: total, References: references, }, nil @@ -818,6 +835,7 @@ func (s *hppV2Service) buildGrowingExpensePart( rows, map[bool]string{false: hppV2PartGrowingDirect, true: hppV2PartGrowingFarm}[farmLevel], map[bool]string{false: "Growing Direct", true: "Growing Farm"}[farmLevel], + []string{hppV2ScopePulletCost}, &HppV2Proration{ Basis: hppV2ProrationPopulation, Numerator: transferTotalQty, @@ -838,7 +856,7 @@ func (s *hppV2Service) buildLayingExpenseDirectPart( return nil, err } - return buildExpensePartFromRows(rows, hppV2PartLayingDirect, "Laying Direct", nil, 1), nil + return buildExpensePartFromRows(rows, hppV2PartLayingDirect, "Laying Direct", []string{hppV2ScopeProductionCost}, nil, 1), nil } func (s *hppV2Service) buildLayingExpenseFarmPart( @@ -893,6 +911,7 @@ func (s *hppV2Service) buildLayingExpenseFarmPart( rows, hppV2PartLayingFarm, "Laying Farm", + []string{hppV2ScopeProductionCost}, &HppV2Proration{ Basis: basis, Numerator: numerator, @@ -903,6 +922,294 @@ func (s *hppV2Service) buildLayingExpenseFarmPart( ), nil } +func (s *hppV2Service) getManualPulletCostComponent( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + periodDate time.Time, +) (*HppV2Component, error) { + if s.hppRepo == nil || contextRow == nil { + return nil, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + if sourceProjectFlockID != 0 && transferTotalQty > 0 { + return nil, nil + } + + manualInput, err := s.hppRepo.GetManualDepreciationInputByProjectFlockID(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if manualInput == nil || manualInput.TotalCost <= 0 || manualInput.CutoverDate.IsZero() { + return nil, nil + } + if dateOnly(periodDate).Before(dateOnly(manualInput.CutoverDate)) { + return nil, nil + } + + farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if len(farmPFKIDs) == 0 { + return nil, nil + } + + totalPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), farmPFKIDs) + if err != nil { + return nil, err + } + if totalPopulation <= 0 { + return nil, nil + } + + targetPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId}) + if err != nil { + return nil, err + } + if targetPopulation <= 0 { + return nil, nil + } + + ratio := targetPopulation / totalPopulation + if ratio <= 0 { + return nil, nil + } + + appliedTotal := manualInput.TotalCost * ratio + part := HppV2ComponentPart{ + Code: hppV2PartManualCutover, + Title: "Manual Cut-over", + Scopes: []string{hppV2ScopePulletCost}, + Total: appliedTotal, + Proration: &HppV2Proration{ + Basis: hppV2ProrationPopulation, + Numerator: targetPopulation, + Denominator: totalPopulation, + Ratio: ratio, + }, + Details: map[string]any{ + "cutover_date": formatDateOnly(manualInput.CutoverDate), + "farm_total_cost": manualInput.TotalCost, + "target_population": targetPopulation, + "farm_population": totalPopulation, + }, + References: []HppV2Reference{ + { + Type: "farm_depreciation_manual_input", + ID: manualInput.ID, + Date: formatDateOnly(manualInput.CutoverDate), + Qty: 1, + Total: manualInput.TotalCost, + AppliedTotal: appliedTotal, + }, + }, + } + + return &HppV2Component{ + Code: hppV2ComponentManualPulletCost, + Title: "Manual Pullet Cost", + Scopes: []string{hppV2ScopePulletCost}, + Total: appliedTotal, + Parts: []HppV2ComponentPart{part}, + }, nil +} + +func (s *hppV2Service) getDepreciationComponent( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + periodDate time.Time, + totalPulletCost float64, +) (*HppV2Component, error) { + if s.hppRepo == nil || contextRow == nil || totalPulletCost <= 0 { + return nil, nil + } + + transferInput, err := s.hppRepo.GetLatestTransferInputByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate) + if err != nil { + return nil, err + } + + var part *HppV2ComponentPart + if transferInput != nil && transferInput.SourceProjectFlockID > 0 { + part, err = s.buildNormalTransferDepreciationPart(contextRow, transferInput, periodDate, totalPulletCost) + if err != nil { + return nil, err + } + } else { + part, err = s.buildManualCutoverDepreciationPart(projectFlockKandangId, contextRow, periodDate, totalPulletCost) + if err != nil { + return nil, err + } + } + if part == nil { + return nil, nil + } + + return &HppV2Component{ + Code: hppV2ComponentDepreciation, + Title: "Depreciation", + Scopes: []string{hppV2ScopeProductionCost}, + Total: part.Total, + Parts: []HppV2ComponentPart{*part}, + }, nil +} + +func (s *hppV2Service) buildNormalTransferDepreciationPart( + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + transferInput *commonRepo.HppV2LatestTransferInputRow, + periodDate time.Time, + totalPulletCost float64, +) (*HppV2ComponentPart, error) { + if contextRow == nil || transferInput == nil || totalPulletCost <= 0 { + return nil, nil + } + + originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), transferInput.SourceProjectFlockID) + if err != nil { + return nil, err + } + if originDate == nil || originDate.IsZero() { + return nil, nil + } + + scheduleDay := DepreciationScheduleDay(*originDate, periodDate, contextRow.HouseType) + if scheduleDay <= 0 { + return nil, nil + } + + houseType := NormalizeDepreciationHouseType(contextRow.HouseType) + percentByHouseType, err := s.hppRepo.GetDepreciationPercents(context.Background(), []string{houseType}, scheduleDay) + if err != nil { + return nil, err + } + + pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN( + totalPulletCost, + scheduleDay, + contextRow.HouseType, + percentByHouseType, + ) + if depreciationValue <= 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartDepreciationNormal, + Title: "Normal Transfer", + Scopes: []string{hppV2ScopeProductionCost}, + Total: depreciationValue, + Details: map[string]any{ + "basis_total": totalPulletCost, + "pullet_cost_day_n": pulletCostDayN, + "depreciation_percent": depreciationPercent, + "schedule_day": scheduleDay, + "origin_date": formatDateOnly(*originDate), + "transfer_date": formatDateOnly(transferInput.TransferDate), + "source_project_flock_id": transferInput.SourceProjectFlockID, + }, + References: []HppV2Reference{ + { + Type: "laying_transfer", + ID: transferInput.TransferID, + Date: formatDateOnly(transferInput.TransferDate), + Qty: transferInput.TransferQty, + Total: totalPulletCost, + AppliedTotal: depreciationValue, + }, + }, + }, nil +} + +func (s *hppV2Service) buildManualCutoverDepreciationPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + periodDate time.Time, + totalPulletCost float64, +) (*HppV2ComponentPart, error) { + if contextRow == nil || totalPulletCost <= 0 { + return nil, nil + } + + manualInput, err := s.hppRepo.GetManualDepreciationInputByProjectFlockID(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if manualInput == nil || manualInput.TotalCost <= 0 || manualInput.CutoverDate.IsZero() { + return nil, nil + } + if dateOnly(periodDate).Before(dateOnly(manualInput.CutoverDate)) { + return nil, nil + } + + originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if originDate == nil || originDate.IsZero() { + return nil, nil + } + + reportScheduleDay := DepreciationScheduleDay(*originDate, periodDate, contextRow.HouseType) + if reportScheduleDay <= 0 { + return nil, nil + } + + cutoverScheduleDay := DepreciationScheduleDay(*originDate, manualInput.CutoverDate, contextRow.HouseType) + startDay := 1 + if cutoverScheduleDay > 0 { + startDay = cutoverScheduleDay + } + + houseType := NormalizeDepreciationHouseType(contextRow.HouseType) + percentByHouseType, err := s.hppRepo.GetDepreciationPercents(context.Background(), []string{houseType}, reportScheduleDay) + if err != nil { + return nil, err + } + + pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange( + totalPulletCost, + startDay, + reportScheduleDay, + contextRow.HouseType, + percentByHouseType, + ) + if depreciationValue <= 0 { + return nil, nil + } + + return &HppV2ComponentPart{ + Code: hppV2PartDepreciationCutover, + Title: "Manual Cut-over", + Scopes: []string{hppV2ScopeProductionCost}, + Total: depreciationValue, + Details: map[string]any{ + "basis_total": totalPulletCost, + "pullet_cost_day_n": pulletCostDayN, + "depreciation_percent": depreciationPercent, + "schedule_day": reportScheduleDay, + "start_schedule_day": startDay, + "origin_date": formatDateOnly(*originDate), + "cutover_date": formatDateOnly(manualInput.CutoverDate), + "manual_input_id": manualInput.ID, + "project_flock_kandang": projectFlockKandangId, + }, + References: []HppV2Reference{ + { + Type: "farm_depreciation_manual_input", + ID: manualInput.ID, + Date: formatDateOnly(manualInput.CutoverDate), + Qty: 1, + Total: totalPulletCost, + AppliedTotal: depreciationValue, + }, + }, + }, nil +} + func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { if s.hppRepo == nil { return &HppCostResponse{}, nil @@ -975,6 +1282,7 @@ func buildExpensePartFromRows( rows []commonRepo.HppV2ExpenseCostRow, code string, title string, + scopes []string, proration *HppV2Proration, ratio float64, ) *HppV2ComponentPart { @@ -1005,6 +1313,7 @@ func buildExpensePartFromRows( return &HppV2ComponentPart{ Code: code, Title: title, + Scopes: append([]string{}, scopes...), Total: total, Proration: proration, References: references, @@ -1015,6 +1324,7 @@ func buildChickinPartFromRows( rows []commonRepo.HppV2ChickinCostRow, code string, title string, + scopes []string, proration *HppV2Proration, ratio float64, ) *HppV2ComponentPart { @@ -1048,6 +1358,7 @@ func buildChickinPartFromRows( return &HppV2ComponentPart{ Code: code, Title: title, + Scopes: append([]string{}, scopes...), Total: total, Proration: proration, References: references, @@ -1065,3 +1376,48 @@ func componentHasScope(component *HppV2Component, scope string) bool { } return false } + +func componentScopeTotal(component *HppV2Component, scope string) float64 { + if component == nil || scope == "" { + return 0 + } + + total := 0.0 + hasPartScopes := false + for _, part := range component.Parts { + if len(part.Scopes) == 0 { + continue + } + hasPartScopes = true + if partHasScope(&part, scope) { + total += part.Total + } + } + if hasPartScopes { + return total + } + if componentHasScope(component, scope) { + return component.Total + } + return 0 +} + +func partHasScope(part *HppV2ComponentPart, scope string) bool { + if part == nil || scope == "" { + return false + } + for _, candidate := range part.Scopes { + if candidate == scope { + return true + } + } + return false +} + +func dateOnly(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location()) +} + +func formatDateOnly(value time.Time) string { + return dateOnly(value).Format("2006-01-02") +} diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go index f3dd0747..574bad8a 100644 --- a/internal/common/service/common.hppv2.service_test.go +++ b/internal/common/service/common.hppv2.service_test.go @@ -15,6 +15,10 @@ import ( type hppV2RepoStub struct { contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext pfkIDsByProject map[uint][]uint + latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow + manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow + chickInDateByProject map[uint]*time.Time + depreciationByHouse map[string]map[int]float64 usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow @@ -47,6 +51,35 @@ func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFloc return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil } +func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) (*commonRepo.HppV2LatestTransferInputRow, error) { + return s.latestTransferByPFK[projectFlockKandangId], nil +} + +func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) { + return s.manualInputByProject[projectFlockID], nil +} + +func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) { + return s.chickInDateByProject[projectFlockID], nil +} + +func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) { + result := make(map[string]map[int]float64) + for _, houseType := range houseTypes { + source := s.depreciationByHouse[houseType] + if len(source) == 0 { + continue + } + result[houseType] = make(map[int]float64) + for day, pct := range source { + if day <= maxDay { + result[houseType][day] = pct + } + } + } + return result, nil +} + func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) { return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil } @@ -161,8 +194,11 @@ func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) { if result == nil { t.Fatal("expected breakdown result") } - if got := result.TotalProductionCost; got != 2950 { - t.Fatalf("expected total production cost 2950, got %v", got) + if got := result.TotalPulletCost; got != 1150 { + t.Fatalf("expected total pullet cost 1150, got %v", got) + } + if got := result.TotalProductionCost; got != 1800 { + t.Fatalf("expected total production cost 1800, got %v", got) } if len(result.Components) != 1 { t.Fatalf("expected 1 component, got %d", len(result.Components)) @@ -190,11 +226,11 @@ func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) { if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 { t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration) } - if result.Hpp.Estimation.HargaKg != 295 { - t.Fatalf("expected estimation harga/kg 295, got %v", result.Hpp.Estimation.HargaKg) + if result.Hpp.Estimation.HargaKg != 180 { + t.Fatalf("expected estimation harga/kg 180, got %v", result.Hpp.Estimation.HargaKg) } - if result.Hpp.Real.HargaKg != 737.5 { - t.Fatalf("expected real harga/kg 737.5, got %v", result.Hpp.Real.HargaKg) + if result.Hpp.Real.HargaKg != 450 { + t.Fatalf("expected real harga/kg 450, got %v", result.Hpp.Real.HargaKg) } } @@ -336,11 +372,14 @@ func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) { if componentTotals[hppV2ComponentOvk] != 450 { t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk]) } - if result.TotalProductionCost != 950 { - t.Fatalf("expected total production cost 950, got %v", result.TotalProductionCost) + if result.TotalPulletCost != 250 { + t.Fatalf("expected total pullet cost 250, got %v", result.TotalPulletCost) } - if result.Hpp.Estimation.HargaKg != 79.17 { - t.Fatalf("expected estimation harga/kg 79.17, got %v", result.Hpp.Estimation.HargaKg) + if result.TotalProductionCost != 700 { + t.Fatalf("expected total production cost 700, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 58.33 { + t.Fatalf("expected estimation harga/kg 58.33, got %v", result.Hpp.Estimation.HargaKg) } } @@ -503,11 +542,204 @@ func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) if componentTotals[hppV2ComponentBopEksp] != 88 { t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp]) } - if result.TotalProductionCost != 358 { - t.Fatalf("expected total production cost 358, got %v", result.TotalProductionCost) + if result.TotalPulletCost != 190 { + t.Fatalf("expected total pullet cost 190, got %v", result.TotalPulletCost) } - if result.Hpp.Estimation.HargaKg != 119.33 { - t.Fatalf("expected estimation harga/kg 119.33, got %v", result.Hpp.Estimation.HargaKg) + if result.TotalProductionCost != 168 { + t.Fatalf("expected total production cost 168, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 56 { + t.Fatalf("expected estimation harga/kg 56, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_AddsDepreciationForNormalTransfer(t *testing.T) { + sourceChickIn := mustTime(t, "2026-01-01") + reportDate := sourceChickIn.AddDate(0, 0, 154) + + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 50: { + ProjectFlockKandangID: 50, + ProjectFlockID: 10, + ProjectFlockCategory: "LAYING", + KandangID: 500, + KandangName: "Kandang F", + LocationID: 21, + HouseType: "close_house", + }, + }, + pfkIDsByProject: map[uint][]uint{ + 11: {501}, + }, + latestTransferByPFK: map[uint]*commonRepo.HppV2LatestTransferInputRow{ + 50: { + ProjectFlockKandangID: 50, + SourceProjectFlockID: 11, + TransferDate: mustTime(t, "2026-05-20"), + TransferQty: 100, + TransferID: 701, + }, + }, + chickInDateByProject: map[uint]*time.Time{ + 11: &sourceChickIn, + }, + depreciationByHouse: map[string]map[int]float64{ + "close_house": { + 1: 10, + }, + }, + usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ + stubKey([]uint{501}, []string{"PAKAN"}): { + {StockableType: "purchase_items", StockableID: 9301, SourceProductID: 41, SourceProductName: "Pakan Growing", Qty: 25, UnitPrice: 40, TotalCost: 1000}, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{501}, nil): 100, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 50: {projectFlockID: 11, totalQty: 100}, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 50: {pieces: 20, kg: 10}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(50, &reportDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if result.TotalPulletCost != 1000 { + t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost) + } + if result.TotalProductionCost != 100 { + t.Fatalf("expected total production cost 100, got %v", result.TotalProductionCost) + } + + var depreciation *HppV2Component + for i := range result.Components { + if result.Components[i].Code == hppV2ComponentDepreciation { + depreciation = &result.Components[i] + break + } + } + if depreciation == nil { + t.Fatal("expected depreciation component") + } + if depreciation.Total != 100 { + t.Fatalf("expected depreciation total 100, got %v", depreciation.Total) + } + if len(depreciation.Parts) != 1 { + t.Fatalf("expected single depreciation part, got %d", len(depreciation.Parts)) + } + if depreciation.Parts[0].Details["schedule_day"] != 1 { + t.Fatalf("expected schedule day 1, got %+v", depreciation.Parts[0].Details) + } + if depreciation.Parts[0].Details["origin_date"] != "2026-01-01" { + t.Fatalf("expected origin date 2026-01-01, got %+v", depreciation.Parts[0].Details) + } + if result.Hpp.Estimation.HargaKg != 10 { + t.Fatalf("expected estimation harga/kg 10, got %v", result.Hpp.Estimation.HargaKg) + } +} + +func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverDate(t *testing.T) { + originDate := mustTime(t, "2026-01-01") + cutoverDate := originDate.AddDate(0, 0, 155) + + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 60: { + ProjectFlockKandangID: 60, + ProjectFlockID: 12, + ProjectFlockCategory: "LAYING", + KandangID: 600, + KandangName: "Kandang G", + LocationID: 22, + HouseType: "close_house", + }, + }, + pfkIDsByProject: map[uint][]uint{ + 12: {60}, + }, + manualInputByProject: map[uint]*commonRepo.HppV2ManualDepreciationInputRow{ + 12: { + ID: 801, + ProjectFlockID: 12, + TotalCost: 1000, + CutoverDate: cutoverDate, + }, + }, + chickInDateByProject: map[uint]*time.Time{ + 12: &originDate, + }, + depreciationByHouse: map[string]map[int]float64{ + "close_house": { + 1: 10, + 2: 20, + }, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{60}, nil): 100, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 60: {pieces: 20, kg: 10}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(60, &cutoverDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if result.TotalPulletCost != 1000 { + t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost) + } + if result.TotalProductionCost != 200 { + t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + if componentTotals[hppV2ComponentManualPulletCost] != 1000 { + t.Fatalf("expected manual pullet cost 1000, got %v", componentTotals[hppV2ComponentManualPulletCost]) + } + if componentTotals[hppV2ComponentDepreciation] != 200 { + t.Fatalf("expected depreciation 200, got %v", componentTotals[hppV2ComponentDepreciation]) + } + + var depreciation *HppV2Component + for i := range result.Components { + if result.Components[i].Code == hppV2ComponentDepreciation { + depreciation = &result.Components[i] + break + } + } + if depreciation == nil || len(depreciation.Parts) != 1 { + t.Fatalf("expected one depreciation part, got %+v", depreciation) + } + if depreciation.Parts[0].Details["schedule_day"] != 2 { + t.Fatalf("expected schedule day 2, got %+v", depreciation.Parts[0].Details) + } + if depreciation.Parts[0].Details["start_schedule_day"] != 2 { + t.Fatalf("expected start schedule day 2, got %+v", depreciation.Parts[0].Details) + } + if result.Hpp.Estimation.HargaKg != 20 { + t.Fatalf("expected estimation harga/kg 20, got %v", result.Hpp.Estimation.HargaKg) } } diff --git a/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql b/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql new file mode 100644 index 00000000..0dce0ea1 --- /dev/null +++ b/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_cutover_date; + +ALTER TABLE farm_depreciation_manual_inputs + DROP COLUMN IF EXISTS cutover_date; diff --git a/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql b/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql new file mode 100644 index 00000000..20abc16e --- /dev/null +++ b/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql @@ -0,0 +1,12 @@ +ALTER TABLE farm_depreciation_manual_inputs + ADD COLUMN IF NOT EXISTS cutover_date DATE; + +UPDATE farm_depreciation_manual_inputs +SET cutover_date = COALESCE(cutover_date, DATE(created_at)) +WHERE cutover_date IS NULL; + +ALTER TABLE farm_depreciation_manual_inputs + ALTER COLUMN cutover_date SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_cutover_date + ON farm_depreciation_manual_inputs (cutover_date); diff --git a/internal/entities/farm_depreciation_manual_input.go b/internal/entities/farm_depreciation_manual_input.go index 2e10ee56..ee4f9989 100644 --- a/internal/entities/farm_depreciation_manual_input.go +++ b/internal/entities/farm_depreciation_manual_input.go @@ -6,6 +6,7 @@ type FarmDepreciationManualInput struct { Id uint `gorm:"primaryKey"` ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_manual_inputs_unique"` TotalCost float64 `gorm:"type:numeric(18,3);not null;default:0"` + CutoverDate time.Time `gorm:"type:date;not null"` Note *string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/modules/repports/dto/repportExpenseDepreciation.dto.go b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go index a968da9c..e7e3f4fd 100644 --- a/internal/modules/repports/dto/repportExpenseDepreciation.dto.go +++ b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go @@ -30,6 +30,7 @@ type ExpenseDepreciationManualInputRowDTO struct { ProjectFlockID int64 `json:"project_flock_id"` FarmName string `json:"farm_name"` TotalCost float64 `json:"total_cost"` + CutoverDate string `json:"cutover_date"` Note *string `json:"note"` } diff --git a/internal/modules/repports/repositories/expense_depreciation.repository.go b/internal/modules/repports/repositories/expense_depreciation.repository.go index c9897a1a..7e058a0b 100644 --- a/internal/modules/repports/repositories/expense_depreciation.repository.go +++ b/internal/modules/repports/repositories/expense_depreciation.repository.go @@ -33,6 +33,7 @@ type FarmDepreciationManualInputRow struct { ProjectFlockID uint FarmName string TotalCost float64 + CutoverDate time.Time Note *string } @@ -271,6 +272,7 @@ func (r *expenseDepreciationRepository) GetLatestManualInputsByFarms( fdmi.project_flock_id AS project_flock_id, pf.flock_name AS farm_name, fdmi.total_cost AS total_cost, + fdmi.cutover_date AS cutover_date, fdmi.note AS note `). Joins("JOIN project_flocks AS pf ON pf.id = fdmi.project_flock_id"). @@ -308,9 +310,10 @@ func (r *expenseDepreciationRepository) UpsertManualInput(ctx context.Context, r {Name: "project_flock_id"}, }, DoUpdates: clause.Assignments(map[string]any{ - "total_cost": row.TotalCost, - "note": row.Note, - "updated_at": now, + "total_cost": row.TotalCost, + "cutover_date": row.CutoverDate, + "note": row.Note, + "updated_at": now, }), }). Create(row).Error @@ -320,7 +323,7 @@ func (r *expenseDepreciationRepository) UpsertManualInput(ctx context.Context, r return r.db.WithContext(ctx). Table("farm_depreciation_manual_inputs"). - Select("id, project_flock_id, total_cost, note"). + Select("id, project_flock_id, total_cost, cutover_date, note"). Where("project_flock_id = ?", row.ProjectFlockId). Take(row).Error } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 87a0605a..edd90f04 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -347,6 +347,7 @@ func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]d ProjectFlockID: int64(row.ProjectFlockID), FarmName: row.FarmName, TotalCost: row.TotalCost, + CutoverDate: row.CutoverDate.Format("2006-01-02"), Note: row.Note, }) } @@ -397,10 +398,19 @@ func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, re if s.ExpenseDepreciationRepo == nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured") } + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + cutoverDate, err := time.ParseInLocation("2006-01-02", req.CutoverDate, location) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "cutover_date must follow format YYYY-MM-DD") + } row := entity.FarmDepreciationManualInput{ ProjectFlockId: req.ProjectFlockID, TotalCost: req.TotalCost, + CutoverDate: cutoverDate, Note: req.Note, } if err := s.ExpenseDepreciationRepo.UpsertManualInput(ctx.Context(), &row); err != nil { @@ -411,6 +421,7 @@ func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, re ID: int64(row.Id), ProjectFlockID: int64(row.ProjectFlockId), TotalCost: row.TotalCost, + CutoverDate: row.CutoverDate.Format("2006-01-02"), Note: row.Note, } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 27f1d741..f34e2702 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -92,6 +92,7 @@ type ExpenseDepreciationQuery struct { type ExpenseDepreciationManualInputUpsert struct { ProjectFlockID uint `json:"project_flock_id" validate:"required,gt=0"` TotalCost float64 `json:"total_cost" validate:"required,gte=0"` + CutoverDate string `json:"cutover_date" validate:"required,datetime=2006-01-02"` Note *string `json:"note" validate:"omitempty,max=1000"` } From 04aad18a4cdc5a80be1c9f4eaf3567049f0164a5 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 19 Apr 2026 17:27:42 +0700 Subject: [PATCH 6/8] adjust common hpp v2 --- .../repository/common.hppv2.repository.go | 94 ++-- internal/common/service/common.hpp.service.go | 138 ++---- internal/common/service/common.hppv2.model.go | 1 + .../common/service/common.hppv2.service.go | 225 ++++++++- .../service/common.hppv2.service_test.go | 88 +++- .../repport.expense_depreciation_test.go | 445 ++++++++++++++++++ .../repports/services/repport.service.go | 288 ++++++++---- 7 files changed, 1020 insertions(+), 259 deletions(-) create mode 100644 internal/modules/repports/services/repport.expense_depreciation_test.go diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index 80fe7438..81b59829 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -89,11 +89,21 @@ type HppV2ManualDepreciationInputRow struct { Note *string } +type HppV2FarmDepreciationSnapshotRow struct { + ID uint + ProjectFlockID uint + PeriodDate time.Time + DepreciationPercentEffective float64 + DepreciationValue float64 + PulletCostDayNTotal float64 +} + type HppV2CostRepository interface { GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error) GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error) + GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error) @@ -239,6 +249,29 @@ func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID( return &row, nil } +func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod( + ctx context.Context, + projectFlockID uint, + periodDate time.Time, +) (*HppV2FarmDepreciationSnapshotRow, error) { + var row HppV2FarmDepreciationSnapshotRow + err := r.db.WithContext(ctx). + Table("farm_depreciation_snapshots"). + Select("id, project_flock_id, period_date, depreciation_percent_effective, depreciation_value, pullet_cost_day_n_total"). + Where("project_flock_id = ?", projectFlockID). + Where("period_date = DATE(?)", periodDate). + Limit(1). + Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + + return &row, nil +} + func (r *HppV2RepositoryImpl) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) { type row struct { ChickInDate *time.Time @@ -327,11 +360,11 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags( COALESCE(pi.product_id, ast_pw.product_id, 0) AS source_product_id, COALESCE(pi_prod.name, ast_prod.name, '') AS source_product_name, COALESCE(SUM(sa.qty), 0) AS qty, - CASE + COALESCE(MAX(CASE WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) ELSE 0 - END AS unit_price, + END), 0) AS unit_price, COALESCE(SUM(sa.qty * CASE WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) @@ -367,12 +400,7 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags( sa.stockable_type, sa.stockable_id, COALESCE(pi.product_id, ast_pw.product_id, 0), - COALESCE(pi_prod.name, ast_prod.name, ''), - CASE - WHEN sa.stockable_type = '` + stockablePurchase + `' THEN COALESCE(pi.price, 0) - WHEN sa.stockable_type = '` + stockableAdjustment + `' THEN COALESCE(ast.price, 0) - ELSE 0 - END + COALESCE(pi_prod.name, ast_prod.name, '') `). Order("MIN(r.record_datetime) ASC, sa.stockable_type ASC, sa.stockable_id ASC"). Scan(&rows).Error @@ -417,7 +445,7 @@ func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags( Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN warehouses AS w ON w.id = pw.warehouse_id"). Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). - Where("ast.created_at <= ?", *date). + // Where("ast.created_at <= ?", *date). Where("COALESCE(ast.total_qty, 0) > 0"). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames). Order("ast.created_at ASC, ast.id ASC"). @@ -450,6 +478,15 @@ func (r *HppV2RepositoryImpl) ListChickinCostRowsByProductFlags( stockableTransferToLaying := fifo.StockableKeyTransferToLayingIn.String() usableProjectChickin := fifo.UsableKeyProjectChickin.String() usableStockTransferOut := fifo.UsableKeyStockTransferOut.String() + unitPriceExpr := fmt.Sprintf(` + CASE + WHEN sa.stockable_type = '%s' THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = '%s' THEN COALESCE(ast.price, 0) + WHEN sa.stockable_type = '%s' THEN COALESCE(spi.price, sast.price, 0) + WHEN sa.stockable_type = '%s' THEN COALESCE(tpi.price, tast.price, 0) + ELSE 0 + END + `, stockablePurchase, stockableAdjustment, stockableTransferIn, stockableTransferToLaying) rows := make([]HppV2ChickinCostRow, 0) query := r.db.WithContext(ctx). @@ -479,30 +516,9 @@ func (r *HppV2RepositoryImpl) ListChickinCostRowsByProductFlags( '' ) AS source_product_name, COALESCE(SUM(sa.qty), 0) AS qty, - CASE - WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(spi.price, sast.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, tast.price, 0) - ELSE 0 - END AS unit_price, - COALESCE(SUM(sa.qty * CASE - WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(spi.price, sast.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, tast.price, 0) - ELSE 0 - END), 0) AS total_cost - `, - stockablePurchase, - stockableAdjustment, - stockableTransferIn, - stockableTransferToLaying, - stockablePurchase, - stockableAdjustment, - stockableTransferIn, - stockableTransferToLaying, - ). + `+unitPriceExpr+` AS unit_price, + COALESCE(SUM(sa.qty * (`+unitPriceExpr+`)), 0) AS total_cost + `). Joins( "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, @@ -563,7 +579,7 @@ func (r *HppV2RepositoryImpl) ListChickinCostRowsByProductFlags( } err := query. - Group(` + Group(fmt.Sprintf(` pc.id, pc.project_flock_kandang_id, pc.chick_in_date, @@ -587,14 +603,8 @@ func (r *HppV2RepositoryImpl) ListChickinCostRowsByProductFlags( sast_prod.name, '' ), - CASE - WHEN sa.stockable_type = '` + stockablePurchase + `' THEN COALESCE(pi.price, 0) - WHEN sa.stockable_type = '` + stockableAdjustment + `' THEN COALESCE(ast.price, 0) - WHEN sa.stockable_type = '` + stockableTransferIn + `' THEN COALESCE(spi.price, sast.price, 0) - WHEN sa.stockable_type = '` + stockableTransferToLaying + `' THEN COALESCE(tpi.price, tast.price, 0) - ELSE 0 - END - `). + %s + `, unitPriceExpr)). Order("pc.chick_in_date ASC, pc.id ASC, sa.stockable_type ASC, sa.stockable_id ASC"). Scan(&rows).Error if err != nil { diff --git a/internal/common/service/common.hpp.service.go b/internal/common/service/common.hpp.service.go index 6ea9ffa3..db83d5a6 100644 --- a/internal/common/service/common.hpp.service.go +++ b/internal/common/service/common.hpp.service.go @@ -2,7 +2,6 @@ package service import ( "context" - "log" "math" "time" @@ -40,108 +39,91 @@ func NewHppService(hppRepo commonRepo.HppCostRepository) HppService { } func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) { - logHpp("CalculateHppCost", "start project_flock_kandang_id=%d input_date=%s", projectFlockKandangId, formatTimePtr(date)) if date == nil { now := time.Now() date = &now } - logHpp("CalculateHppCost", "normalized_date=%s", formatTimePtr(date)) location, err := time.LoadLocation("Asia/Jakarta") if err != nil { - logHpp("CalculateHppCost", "load_location_error=%v", err) + return nil, err } - logHpp("CalculateHppCost", "location=%s", location.String()) startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) endOfDay := startOfDay.Add(24 * time.Hour) - logHpp("CalculateHppCost", "start_of_day=%s end_of_day=%s", startOfDay.Format(time.RFC3339), endOfDay.Format(time.RFC3339)) depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay) if err != nil { - logHpp("CalculateHppCost", "get_depresiasi_transfer_error=%v", err) + return nil, err } - logHpp("CalculateHppCost", "depresiasi_transfer=%f", depresiasiTransfer) totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer) if err != nil { - logHpp("CalculateHppCost", "get_total_production_cost_error=%v", err) + return nil, err } - logHpp("CalculateHppCost", "total_production_cost=%f", totalProductionCost) result, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) if err != nil { - logHpp("CalculateHppCost", "get_hpp_estimation_dan_realisasi_error=%v", err) + return nil, err } - logHpp("CalculateHppCost", "done estimation=%+v real=%+v", result.Estimation, result.Real) return result, nil } func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) { - logHpp("GetTotalDepresiasiFlockGrowing", "start source_project_flock_id=%d input_date=%s", sourceProjectFlockID, formatTimePtr(date)) if date == nil { now := time.Now() date = &now } - logHpp("GetTotalDepresiasiFlockGrowing", "normalized_date=%s", formatTimePtr(date)) if s.hppRepo == nil { - logHpp("GetTotalDepresiasiFlockGrowing", "repo_nil return=0") + return 0, nil } kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) if err != nil { - logHpp("GetTotalDepresiasiFlockGrowing", "get_project_flock_kandang_ids_error=%v", err) + return 0, err } - logHpp("GetTotalDepresiasiFlockGrowing", "kandang_ids=%v", kandangIDs) docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs) if err != nil { - logHpp("GetTotalDepresiasiFlockGrowing", "get_doc_cost_error=%v", err) + return 0, err } - logHpp("GetTotalDepresiasiFlockGrowing", "doc_cost=%f", docCost) budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID) if err != nil { - logHpp("GetTotalDepresiasiFlockGrowing", "get_budget_cost_error=%v", err) + return 0, err } - logHpp("GetTotalDepresiasiFlockGrowing", "budget_cost=%f", budgetCost) expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs) if err != nil { - logHpp("GetTotalDepresiasiFlockGrowing", "get_expedision_cost_error=%v", err) + return 0, err } - logHpp("GetTotalDepresiasiFlockGrowing", "expedision_cost=%f", expedisionCost) feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date) if err != nil { - logHpp("GetTotalDepresiasiFlockGrowing", "get_feed_usage_cost_error=%v", err) + return 0, err } - logHpp("GetTotalDepresiasiFlockGrowing", "feed_cost=%f", feedCost) ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date) if err != nil { - logHpp("GetTotalDepresiasiFlockGrowing", "get_ovk_usage_cost_error=%v", err) + return 0, err } - logHpp("GetTotalDepresiasiFlockGrowing", "ovk_cost=%f", ovkCost) total := docCost + budgetCost + expedisionCost + feedCost + ovkCost - logHpp("GetTotalDepresiasiFlockGrowing", "done total=%f", total) return total, nil } func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) { - logHpp("GetTotalProductionCost", "start project_flock_kandang_id=%d end_date=%s depresiasi_transfer=%f", projectFlockKandangId, formatTimePtr(endDate), depresiasiTransfer) // if date == nil { // now := time.Now() // date = &now @@ -149,248 +131,210 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId) if err != nil { - logHpp("GetTotalProductionCost", "get_pullet_cost_error=%v", err) + return 0, err } - logHpp("GetTotalProductionCost", "cost_pullet=%f", costPullet) costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { - logHpp("GetTotalProductionCost", "get_feed_usage_cost_error=%v", err) + return 0, err } - logHpp("GetTotalProductionCost", "cost_feed=%f", costFeed) costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { - logHpp("GetTotalProductionCost", "get_ovk_usage_cost_error=%v", err) + return 0, err } - logHpp("GetTotalProductionCost", "cost_ovk=%f", costOvk) costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId}) if err != nil { - logHpp("GetTotalProductionCost", "get_expedision_cost_error=%v", err) + return 0, err } - logHpp("GetTotalProductionCost", "cost_expedision=%f", costExpedision) costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate) if err != nil { - logHpp("GetTotalProductionCost", "get_budget_kandang_laying_error=%v", err) + return 0, err } - logHpp("GetTotalProductionCost", "cost_budget=%f", costBudget) // fmt.Println(costBudget, costExpedision, costOvk, costFeed, costPullet, depresiasiTransfer) // depresiasiTransfer = 0 total := depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget - logHpp("GetTotalProductionCost", "done total=%f", total) return total, nil } func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - logHpp("GetBudgetKandangLaying", "start project_flock_kandang_id=%d end_date=%s", projectFlockKandangId, formatTimePtr(endDate)) // if date == nil { // now := time.Now() // date = &now // } if s.hppRepo == nil { - logHpp("GetBudgetKandangLaying", "repo_nil return=0") + return 0, nil } projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId) if err != nil { - logHpp("GetBudgetKandangLaying", "get_project_flock_id_error=%v", err) + return 0, err } - logHpp("GetBudgetKandangLaying", "project_flock_id=%d", projectFlockId) projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId) if err != nil { - logHpp("GetBudgetKandangLaying", "get_project_flock_kandang_ids_error=%v", err) + return 0, err } - logHpp("GetBudgetKandangLaying", "project_flock_kandang_ids=%v", projectFlockKandangIds) eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate) if err != nil { - logHpp("GetBudgetKandangLaying", "get_egg_produksi_pieces_flock_error=%v", err) + return 0, err } - logHpp("GetBudgetKandangLaying", "egg_produksi_pieces_flock=%f", eggProduksiPiecesFlock) eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { - logHpp("GetBudgetKandangLaying", "get_egg_produksi_pieces_kandang_error=%v", err) + return 0, err } - logHpp("GetBudgetKandangLaying", "egg_produksi_pieces_kandang=%f", eggProduksiPiecesKandang) totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId) if err != nil { - logHpp("GetBudgetKandangLaying", "get_budget_cost_error=%v", err) + return 0, err } - logHpp("GetBudgetKandangLaying", "total_budget_cost=%f", totalBudgetCost) if eggProduksiPiecesFlock == 0 { - logHpp("GetBudgetKandangLaying", "egg_produksi_pieces_flock_zero return=0") + return 0, nil } result := (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock - logHpp("GetBudgetKandangLaying", "done result=%f", result) return result, nil } func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) { - logHpp("GetDepresiasiTransfer", "start project_flock_kandang_id=%d end_date=%s", projectFlockKandangId, formatTimePtr(endDate)) if endDate == nil { now := time.Now() endDate = &now } - logHpp("GetDepresiasiTransfer", "normalized_end_date=%s", formatTimePtr(endDate)) if s.hppRepo == nil { - logHpp("GetDepresiasiTransfer", "repo_nil return=0") + return 0, nil } sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) if err != nil { - logHpp("GetDepresiasiTransfer", "get_transfer_source_summary_error=%v", err) return 0, err } - logHpp("GetDepresiasiTransfer", "source_project_flock_id=%d transfer_total_qty=%f", sourceProjectFlockID, transferTotalQty) if sourceProjectFlockID == 0 || transferTotalQty <= 0 { - logHpp("GetDepresiasiTransfer", "use_manual_fallback=true") result, fallbackErr := s.getManualDepresiasiTransferFallback(projectFlockKandangId) if fallbackErr != nil { - logHpp("GetDepresiasiTransfer", "manual_fallback_error=%v", fallbackErr) return 0, fallbackErr } - logHpp("GetDepresiasiTransfer", "done_fallback result=%f", result) return result, nil } kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) if err != nil { - logHpp("GetDepresiasiTransfer", "get_project_flock_kandang_ids_error=%v", err) return 0, err } - logHpp("GetDepresiasiTransfer", "kandang_ids_growing=%v", kandangIDsGrowing) totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) if err != nil { - logHpp("GetDepresiasiTransfer", "get_total_population_error=%v", err) return 0, err } - logHpp("GetDepresiasiTransfer", "total_population_flock_growing=%f", totalPopulationFlockGrowing) if totalPopulationFlockGrowing == 0 { - logHpp("GetDepresiasiTransfer", "total_population_flock_growing_zero return=0") return 0, nil } totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, endDate) if err != nil { - logHpp("GetDepresiasiTransfer", "get_total_depresiasi_flock_growing_error=%v", err) return 0, err } - logHpp("GetDepresiasiTransfer", "total_depresiasi_flock_growing=%f", totalDepresiasiFlockGrowing) result := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing - logHpp("GetDepresiasiTransfer", "done result=%f", result) return result, nil } func (s *hppService) getManualDepresiasiTransferFallback(projectFlockKandangId uint) (float64, error) { - logHpp("getManualDepresiasiTransferFallback", "start project_flock_kandang_id=%d", projectFlockKandangId) projectFlockID, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId) if err != nil { - logHpp("getManualDepresiasiTransferFallback", "get_project_flock_id_error=%v", err) + return 0, err } - logHpp("getManualDepresiasiTransferFallback", "project_flock_id=%d", projectFlockID) if projectFlockID == 0 { - logHpp("getManualDepresiasiTransferFallback", "project_flock_id_zero return=0") + return 0, nil } manualCost, err := s.hppRepo.GetManualDepreciationCostByProjectFlockID(context.Background(), projectFlockID) if err != nil { - logHpp("getManualDepresiasiTransferFallback", "get_manual_depreciation_cost_error=%v", err) + return 0, err } - logHpp("getManualDepresiasiTransferFallback", "manual_cost=%f", manualCost) if manualCost <= 0 { - logHpp("getManualDepresiasiTransferFallback", "manual_cost_non_positive return=0") + return 0, nil } kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockID) if err != nil { - logHpp("getManualDepresiasiTransferFallback", "get_project_flock_kandang_ids_error=%v", err) + return 0, err } - logHpp("getManualDepresiasiTransferFallback", "kandang_ids=%v", kandangIDs) if len(kandangIDs) == 0 { - logHpp("getManualDepresiasiTransferFallback", "kandang_ids_empty return=0") + return 0, nil } totalUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDs) if err != nil { - logHpp("getManualDepresiasiTransferFallback", "get_total_usage_qty_error=%v", err) + return 0, err } - logHpp("getManualDepresiasiTransferFallback", "total_usage_qty=%f", totalUsageQty) if totalUsageQty <= 0 { - logHpp("getManualDepresiasiTransferFallback", "total_usage_qty_non_positive return=0") + return 0, nil } kandangUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId}) if err != nil { - logHpp("getManualDepresiasiTransferFallback", "get_kandang_usage_qty_error=%v", err) + return 0, err } - logHpp("getManualDepresiasiTransferFallback", "kandang_usage_qty=%f", kandangUsageQty) if kandangUsageQty <= 0 { - logHpp("getManualDepresiasiTransferFallback", "kandang_usage_qty_non_positive return=0") + return 0, nil } result := manualCost * (kandangUsageQty / totalUsageQty) - logHpp("getManualDepresiasiTransferFallback", "done result=%f", result) return result, nil } func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { - logHpp("GetHppEstimationDanRealisasi", "start total_production_cost=%f project_flock_kandang_id=%d start_date=%s end_date=%s", totalProductionCost, projectFlockKandangId, formatTimePtr(startDate), formatTimePtr(endDate)) if s.hppRepo == nil { - logHpp("GetHppEstimationDanRealisasi", "repo_nil return_empty_response") + return &HppCostResponse{}, nil } estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { - logHpp("GetHppEstimationDanRealisasi", "get_egg_produksi_error=%v", err) + return nil, err } - logHpp("GetHppEstimationDanRealisasi", "estim_pieces=%f estim_weight_kg=%f", estimPieces, estimWeightKg) realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate) if err != nil { - logHpp("GetHppEstimationDanRealisasi", "get_egg_terjual_error=%v", err) + return nil, err } - logHpp("GetHppEstimationDanRealisasi", "real_pieces=%f real_weight_kg=%f", realPieces, realWeightKg) estimation := HppCostDetail{ Total: totalProductionCost, @@ -403,7 +347,6 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p if estimPieces > 0 { estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces) } - logHpp("GetHppEstimationDanRealisasi", "estimation=%+v", estimation) real := HppCostDetail{ Total: totalProductionCost, @@ -416,19 +359,16 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p if realPieces > 0 { real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) } - logHpp("GetHppEstimationDanRealisasi", "real=%+v", real) result := &HppCostResponse{ Estimation: estimation, Real: real, } - logHpp("GetHppEstimationDanRealisasi", "done response=%+v", *result) return result, nil } func roundToTwoDecimals(value float64) float64 { result := math.Round(value*100) / 100 - logHpp("roundToTwoDecimals", "input=%f output=%f", value, result) return result } @@ -438,7 +378,3 @@ func formatTimePtr(value *time.Time) string { } return value.Format(time.RFC3339) } - -func logHpp(method, format string, args ...any) { - log.Printf("[HPP][%s] "+format, append([]any{method}, args...)...) -} diff --git a/internal/common/service/common.hppv2.model.go b/internal/common/service/common.hppv2.model.go index faf7cb33..f6f94bf9 100644 --- a/internal/common/service/common.hppv2.model.go +++ b/internal/common/service/common.hppv2.model.go @@ -48,6 +48,7 @@ type HppV2Breakdown struct { ProjectFlockKandangID uint `json:"project_flock_kandang_id"` ProjectFlockID uint `json:"project_flock_id"` ProjectFlockCategory string `json:"project_flock_category,omitempty"` + HouseType string `json:"house_type,omitempty"` KandangID uint `json:"kandang_id,omitempty"` KandangName string `json:"kandang_name,omitempty"` LocationID uint `json:"location_id,omitempty"` diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index bebbc9b3..c392cf8b 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -28,13 +28,14 @@ const ( hppV2PartManualCutover = "manual_cutover" hppV2PartDepreciationNormal = "normal_transfer" hppV2PartDepreciationCutover = "manual_cutover" + hppV2PartDepreciationFarmSnapshot = "farm_snapshot" hppV2ProrationPopulation = "growing_population_share" hppV2ProrationEggWeight = "laying_egg_weight_share" hppV2ProrationEggPiece = "laying_egg_piece_share" hppV2ScopePulletCost = "pullet_cost" hppV2ScopeProductionCost = "production_cost" hppV2CutoverFlagPakan = "PAKAN-CUTOVER" - hppV2CutoverFlagOvk = "OVK-CUTOVER" + hppV2CutoverFlagOvk = "OVK" ) type HppV2Service interface { @@ -115,57 +116,101 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t totalPulletCost := 0.0 totalProductionCost := 0.0 components := make([]HppV2Component, 0, 8) - appendComponent := func(component *HppV2Component) { + appendComponent := func(requestedCode string, component *HppV2Component) { + pulletBefore := totalPulletCost + productionBefore := totalProductionCost + if component == nil || (component.Total == 0 && len(component.Parts) == 0) { + utils.Log.Infof( + "HPP v2 component skipped: project_flock_kandang_id=%d period_date=%s component=%s reason=empty_or_nil total_pullet_cost=%.2f total_production_cost=%.2f", + projectFlockKandangId, + startOfDay.Format("2006-01-02"), + requestedCode, + totalPulletCost, + totalProductionCost, + ) return } + + pulletAdded := componentScopeTotal(component, hppV2ScopePulletCost) + productionAdded := componentScopeTotal(component, hppV2ScopeProductionCost) components = append(components, *component) - totalPulletCost += componentScopeTotal(component, hppV2ScopePulletCost) - totalProductionCost += componentScopeTotal(component, hppV2ScopeProductionCost) + totalPulletCost += pulletAdded + totalProductionCost += productionAdded + utils.Log.Infof( + "HPP v2 component applied: project_flock_kandang_id=%d period_date=%s component=%s component_total=%.2f pullet_added=%.2f production_added=%.2f total_pullet_before=%.2f total_pullet_after=%.2f total_production_before=%.2f total_production_after=%.2f parts_count=%d", + projectFlockKandangId, + startOfDay.Format("2006-01-02"), + component.Code, + component.Total, + pulletAdded, + productionAdded, + pulletBefore, + totalPulletCost, + productionBefore, + totalProductionCost, + len(component.Parts), + ) } - appendComponent(pakanComponent) + appendComponent(hppV2ComponentPakan, pakanComponent) ovkComponent, err := s.GetOvkBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - appendComponent(ovkComponent) + appendComponent(hppV2ComponentOvk, ovkComponent) docComponent, err := s.GetDocChickinBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - appendComponent(docComponent) + appendComponent(hppV2ComponentDocChickin, docComponent) directPulletComponent, err := s.GetDirectPulletPurchaseBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - appendComponent(directPulletComponent) + appendComponent(hppV2ComponentDirectPulletPurchase, directPulletComponent) bopRegularComponent, err := s.GetBopRegularBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - appendComponent(bopRegularComponent) + appendComponent(hppV2ComponentBopRegular, bopRegularComponent) bopEkspedisiComponent, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - appendComponent(bopEkspedisiComponent) + appendComponent(hppV2ComponentBopEksp, bopEkspedisiComponent) manualPulletComponent, err := s.getManualPulletCostComponent(projectFlockKandangId, contextRow, startOfDay) if err != nil { return nil, err } - appendComponent(manualPulletComponent) + appendComponent(hppV2ComponentManualPulletCost, manualPulletComponent) - depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, totalPulletCost) + depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, endOfDay, totalPulletCost) if err != nil { return nil, err } - appendComponent(depreciationComponent) + + depreciationCostToProduction := componentScopeTotal(depreciationComponent, hppV2ScopeProductionCost) + depreciationSource := "" + if depreciationComponent != nil && len(depreciationComponent.Parts) > 0 { + depreciationSource = depreciationComponent.Parts[0].Code + } + productionCostBeforeDepreciation := totalProductionCost + appendComponent(hppV2ComponentDepreciation, depreciationComponent) + utils.Log.Infof( + "HPP v2 depreciation cost applied: project_flock_kandang_id=%d period_date=%s depreciation_source=%s depreciation_cost=%.2f production_cost_before=%.2f production_cost_after=%.2f", + projectFlockKandangId, + startOfDay.Format("2006-01-02"), + depreciationSource, + depreciationCostToProduction, + productionCostBeforeDepreciation, + totalProductionCost, + ) hppCost, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) if err != nil { @@ -179,6 +224,7 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t ProjectFlockKandangID: projectFlockKandangId, ProjectFlockID: contextRow.ProjectFlockID, ProjectFlockCategory: contextRow.ProjectFlockCategory, + HouseType: contextRow.HouseType, KandangID: contextRow.KandangID, KandangName: contextRow.KandangName, LocationID: contextRow.LocationID, @@ -1022,9 +1068,28 @@ func (s *hppV2Service) getDepreciationComponent( projectFlockKandangId uint, contextRow *commonRepo.HppV2ProjectFlockKandangContext, periodDate time.Time, + endDate time.Time, totalPulletCost float64, ) (*HppV2Component, error) { - if s.hppRepo == nil || contextRow == nil || totalPulletCost <= 0 { + if s.hppRepo == nil || contextRow == nil { + return nil, nil + } + + snapshotPart, err := s.buildFarmSnapshotDepreciationPart(projectFlockKandangId, contextRow, periodDate, endDate) + if err != nil { + return nil, err + } + if snapshotPart != nil { + return &HppV2Component{ + Code: hppV2ComponentDepreciation, + Title: "Depreciation", + Scopes: []string{hppV2ScopeProductionCost}, + Total: snapshotPart.Total, + Parts: []HppV2ComponentPart{*snapshotPart}, + }, nil + } + + if totalPulletCost <= 0 { return nil, nil } @@ -1058,6 +1123,101 @@ func (s *hppV2Service) getDepreciationComponent( }, nil } +func (s *hppV2Service) buildFarmSnapshotDepreciationPart( + projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, + periodDate time.Time, + endDate time.Time, +) (*HppV2ComponentPart, error) { + if contextRow == nil { + return nil, nil + } + + snapshot, err := s.hppRepo.GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(context.Background(), contextRow.ProjectFlockID, periodDate) + if err != nil { + return nil, err + } + if snapshot == nil || snapshot.DepreciationValue <= 0 { + return nil, nil + } + + farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + if len(farmPFKIDs) == 0 { + return nil, nil + } + + end := endDate + targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, &end) + if err != nil { + return nil, err + } + farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, &end) + if err != nil { + return nil, err + } + + basis := hppV2ProrationEggWeight + numerator := targetWeight + denominator := farmWeight + if denominator <= 0 { + basis = hppV2ProrationEggPiece + numerator = targetPieces + denominator = farmPieces + } + if denominator <= 0 { + return nil, nil + } + + ratio := numerator / denominator + if ratio <= 0 { + return nil, nil + } + + appliedDepreciation := snapshot.DepreciationValue * ratio + if appliedDepreciation <= 0 { + return nil, nil + } + appliedPulletCostDayN := snapshot.PulletCostDayNTotal * ratio + depreciationPercent := snapshot.DepreciationPercentEffective + if appliedPulletCostDayN > 0 { + depreciationPercent = (appliedDepreciation / appliedPulletCostDayN) * 100 + } + + return &HppV2ComponentPart{ + Code: hppV2PartDepreciationFarmSnapshot, + Title: "Farm Snapshot", + Scopes: []string{hppV2ScopeProductionCost}, + Total: appliedDepreciation, + Proration: &HppV2Proration{ + Basis: basis, + Numerator: numerator, + Denominator: denominator, + Ratio: ratio, + }, + Details: map[string]any{ + "basis_total": snapshot.DepreciationValue, + "pullet_cost_day_n": appliedPulletCostDayN, + "depreciation_percent": depreciationPercent, + "snapshot_id": snapshot.ID, + "snapshot_period_date": formatDateOnly(snapshot.PeriodDate), + "snapshot_project_flock": snapshot.ProjectFlockID, + }, + References: []HppV2Reference{ + { + Type: "farm_depreciation_snapshot", + ID: snapshot.ID, + Date: formatDateOnly(snapshot.PeriodDate), + Qty: 1, + Total: snapshot.DepreciationValue, + AppliedTotal: appliedDepreciation, + }, + }, + }, nil +} + func (s *hppV2Service) buildNormalTransferDepreciationPart( contextRow *commonRepo.HppV2ProjectFlockKandangContext, transferInput *commonRepo.HppV2LatestTransferInputRow, @@ -1211,17 +1371,40 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart( } func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { + utils.Log.Infof( + "GetHppEstimationDanRealisasi started: project_flock_kandang_id=%d total_production_cost=%.2f start_date=%s end_date=%s", + projectFlockKandangId, + totalProductionCost, + formatTimePtr(startDate), + formatTimePtr(endDate), + ) + if s.hppRepo == nil { + utils.Log.Warnf( + "GetHppEstimationDanRealisasi skipped: hpp repository is nil (project_flock_kandang_id=%d)", + projectFlockKandangId, + ) return &HppCostResponse{}, nil } estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) if err != nil { + utils.Log.WithError(err).Errorf( + "GetHppEstimationDanRealisasi failed to get estimation egg production: project_flock_kandang_id=%d end_date=%s", + projectFlockKandangId, + formatTimePtr(endDate), + ) return nil, err } realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate) if err != nil { + utils.Log.WithError(err).Errorf( + "GetHppEstimationDanRealisasi failed to get realization egg sales: project_flock_kandang_id=%d start_date=%s end_date=%s", + projectFlockKandangId, + formatTimePtr(startDate), + formatTimePtr(endDate), + ) return nil, err } @@ -1249,6 +1432,20 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) } + utils.Log.Infof( + "GetHppEstimationDanRealisasi success: project_flock_kandang_id=%d estimation_butir=%.2f estimation_kg=%.2f estimation_harga_butir=%.2f estimation_harga_kg=%.2f real_butir=%.2f real_kg=%.2f real_harga_butir=%.2f real_harga_kg=%.2f totalProductionCost=%.2f", + projectFlockKandangId, + estimation.Butir, + estimation.Kg, + estimation.HargaButir, + estimation.HargaKg, + real.Butir, + real.Kg, + real.HargaButir, + real.HargaKg, + totalProductionCost, + ) + return &HppCostResponse{ Estimation: estimation, Real: real, diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go index 574bad8a..a2a8d27e 100644 --- a/internal/common/service/common.hppv2.service_test.go +++ b/internal/common/service/common.hppv2.service_test.go @@ -17,6 +17,7 @@ type hppV2RepoStub struct { pfkIDsByProject map[uint][]uint latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow + snapshotByProjectKey map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow chickInDateByProject map[uint]*time.Time depreciationByHouse map[string]map[int]float64 usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow @@ -59,6 +60,13 @@ func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Con return s.manualInputByProject[projectFlockID], nil } +func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) { + if s.snapshotByProjectKey == nil { + return nil, nil + } + return s.snapshotByProjectKey[fmt.Sprintf("%d|%s", projectFlockID, periodDate.Format("2006-01-02"))], nil +} + func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) { return s.chickInDateByProject[projectFlockID], nil } @@ -319,10 +327,10 @@ func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) { }, }, adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ - stubKey([]uint{301, 302}, []string{"OVK-CUTOVER"}): { + stubKey([]uint{301, 302}, []string{"OVK"}): { {AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100}, }, - stubKey([]uint{30}, []string{"OVK-CUTOVER"}): { + stubKey([]uint{30}, []string{"OVK"}): { {AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50}, }, }, @@ -743,6 +751,82 @@ func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverD } } +func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggProduction(t *testing.T) { + reportDate := mustTime(t, "2026-06-05") + + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 70: { + ProjectFlockKandangID: 70, + ProjectFlockID: 15, + ProjectFlockCategory: "LAYING", + KandangID: 700, + KandangName: "Kandang Snapshot", + LocationID: 25, + HouseType: "close_house", + }, + }, + pfkIDsByProject: map[uint][]uint{ + 15: {70, 71}, + }, + snapshotByProjectKey: map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow{ + "15|2026-06-05": { + ID: 901, + ProjectFlockID: 15, + PeriodDate: reportDate, + DepreciationPercentEffective: 10, + DepreciationValue: 1000, + PulletCostDayNTotal: 10000, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 70: {pieces: 200, kg: 20}, + 71: {pieces: 800, kg: 80}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(70, &reportDate) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected breakdown result") + } + + var depreciation *HppV2Component + for i := range result.Components { + if result.Components[i].Code == hppV2ComponentDepreciation { + depreciation = &result.Components[i] + break + } + } + if depreciation == nil { + t.Fatal("expected depreciation component") + } + if depreciation.Total != 200 { + t.Fatalf("expected depreciation total 200, got %v", depreciation.Total) + } + if result.TotalProductionCost != 200 { + t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost) + } + if len(depreciation.Parts) != 1 { + t.Fatalf("expected one depreciation part, got %d", len(depreciation.Parts)) + } + if depreciation.Parts[0].Code != hppV2PartDepreciationFarmSnapshot { + t.Fatalf("expected farm snapshot depreciation part, got %s", depreciation.Parts[0].Code) + } + if depreciation.Parts[0].Proration == nil || depreciation.Parts[0].Proration.Ratio != 0.2 { + t.Fatalf("expected proration ratio 0.2, got %+v", depreciation.Parts[0].Proration) + } + if depreciation.Parts[0].Details["snapshot_id"] != uint(901) { + t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details) + } +} + func stubKey(ids []uint, flags []string) string { idParts := make([]string, 0, len(ids)) for _, id := range ids { diff --git a/internal/modules/repports/services/repport.expense_depreciation_test.go b/internal/modules/repports/services/repport.expense_depreciation_test.go new file mode 100644 index 00000000..820fbaa6 --- /dev/null +++ b/internal/modules/repports/services/repport.expense_depreciation_test.go @@ -0,0 +1,445 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + dto "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" + repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gorm.io/gorm" +) + +type expenseDepreciationRepoMock struct { + repportRepo.ExpenseDepreciationRepository + manualInputs []repportRepo.FarmDepreciationManualInputRow + + upsertedRow *entity.FarmDepreciationManualInput + deleteCalled bool + deleteDate time.Time + deleteFarmIDs []uint +} + +func (m *expenseDepreciationRepoMock) DB() *gorm.DB { + return nil +} + +func (m *expenseDepreciationRepoMock) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error { + if row == nil { + return nil + } + cloned := *row + if cloned.Id == 0 { + cloned.Id = 123 + } + m.upsertedRow = &cloned + row.Id = cloned.Id + return nil +} + +func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error { + m.deleteCalled = true + m.deleteDate = fromDate + m.deleteFarmIDs = append([]uint{}, farmIDs...) + return nil +} + +func (m *expenseDepreciationRepoMock) GetLatestManualInputsByFarms(_ context.Context, _ []int64, _ []int64, _ []int64) ([]repportRepo.FarmDepreciationManualInputRow, error) { + return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil +} + +type hppCostRepoMock struct { + commonRepo.HppCostRepository + kandangIDsByFarm map[uint][]uint +} + +func (m *hppCostRepoMock) GetProjectFlockKandangIDs(_ context.Context, projectFlockID uint) ([]uint, error) { + return append([]uint{}, m.kandangIDsByFarm[projectFlockID]...), nil +} + +type hppV2ServiceMock struct { + approvalService.HppV2Service + breakdownByPFK map[uint]*approvalService.HppV2Breakdown +} + +func (m *hppV2ServiceMock) CalculateHppBreakdown(projectFlockKandangId uint, _ *time.Time) (*approvalService.HppV2Breakdown, error) { + return m.breakdownByPFK[projectFlockKandangId], nil +} + +func TestComputeExpenseDepreciationSnapshots_FromHppV2NormalTransfer(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + svc := &repportService{ + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 1: {10}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 10: { + ProjectFlockKandangID: 10, + KandangID: 100, + KandangName: "Kandang A", + HouseType: "close_house", + Components: []approvalService.HppV2Component{ + { + Code: "DEPRECIATION", + Title: "Depreciation", + Total: 100, + Parts: []approvalService.HppV2ComponentPart{ + { + Code: "normal_transfer", + Total: 100, + Details: map[string]any{ + "schedule_day": 2, + "depreciation_percent": 10.0, + "pullet_cost_day_n": 1000.0, + "source_project_flock_id": 77, + "origin_date": "2026-01-01", + }, + References: []approvalService.HppV2Reference{ + { + Type: "laying_transfer", + ID: 701, + Date: "2026-05-20", + Qty: 150, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{1}, map[uint]string{1: "Farm A"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].DepreciationValue != 100 { + t.Fatalf("expected depreciation value 100, got %v", rows[0].DepreciationValue) + } + if rows[0].PulletCostDayNTotal != 1000 { + t.Fatalf("expected pullet cost day n 1000, got %v", rows[0].PulletCostDayNTotal) + } + assertFloatEqual(t, rows[0].DepreciationPercentEffective, 10) + + components := decodeDepreciationComponents(t, rows[0].Components) + if components.KandangCount != 1 { + t.Fatalf("expected kandang_count 1, got %d", components.KandangCount) + } + entry := components.Kandang[0] + if entry.ProjectFlockKandangID != 10 || entry.KandangID != 100 || entry.KandangName != "Kandang A" { + t.Fatalf("unexpected kandang identity: %+v", entry) + } + if entry.TransferID != 701 || entry.TransferDate != "2026-05-20" || entry.TransferQty != 150 { + t.Fatalf("unexpected transfer metadata: %+v", entry) + } + if entry.DepreciationSource != "normal_transfer" { + t.Fatalf("expected depreciation_source normal_transfer, got %q", entry.DepreciationSource) + } + if entry.ManualInputID != nil || entry.CutoverDate != "" || entry.StartScheduleDay != nil { + t.Fatalf("expected manual fields empty for normal transfer, got %+v", entry) + } +} + +func TestComputeExpenseDepreciationSnapshots_FromHppV2ManualCutover(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + svc := &repportService{ + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 2: {20}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 20: { + ProjectFlockKandangID: 20, + KandangID: 200, + KandangName: "Kandang B", + HouseType: "open_house", + Components: []approvalService.HppV2Component{ + { + Code: "DEPRECIATION", + Title: "Depreciation", + Total: 200, + Parts: []approvalService.HppV2ComponentPart{ + { + Code: "manual_cutover", + Total: 200, + Details: map[string]any{ + "schedule_day": 2, + "start_schedule_day": 2, + "depreciation_percent": 25.0, + "pullet_cost_day_n": 800.0, + "manual_input_id": 901, + "cutover_date": "2026-06-01", + "origin_date": "2026-01-01", + }, + References: []approvalService.HppV2Reference{ + { + Type: "farm_depreciation_manual_input", + ID: 901, + Date: "2026-06-01", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{2}, map[uint]string{2: "Farm B"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + assertFloatEqual(t, rows[0].DepreciationPercentEffective, 25) + + components := decodeDepreciationComponents(t, rows[0].Components) + if components.KandangCount != 1 { + t.Fatalf("expected kandang_count 1, got %d", components.KandangCount) + } + entry := components.Kandang[0] + if entry.DepreciationSource != "manual_cutover" { + t.Fatalf("expected depreciation_source manual_cutover, got %q", entry.DepreciationSource) + } + if entry.TransferID != 0 || entry.TransferDate != "" || entry.TransferQty != 0 { + t.Fatalf("expected transfer fields empty for manual path, got %+v", entry) + } + if entry.ManualInputID == nil || *entry.ManualInputID != 901 { + t.Fatalf("expected manual_input_id 901, got %+v", entry.ManualInputID) + } + if entry.CutoverDate != "2026-06-01" || entry.OriginDate != "2026-01-01" { + t.Fatalf("unexpected manual date fields: %+v", entry) + } + if entry.StartScheduleDay == nil || *entry.StartScheduleDay != 2 { + t.Fatalf("expected start_schedule_day 2, got %+v", entry.StartScheduleDay) + } +} + +func TestComputeExpenseDepreciationSnapshots_AggregatesMultipleKandang(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + svc := &repportService{ + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 3: {30, 31}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 30: { + ProjectFlockKandangID: 30, + KandangID: 300, + KandangName: "Kandang C1", + Components: []approvalService.HppV2Component{ + { + Code: "DEPRECIATION", + Parts: []approvalService.HppV2ComponentPart{ + { + Code: "normal_transfer", + Total: 50, + Details: map[string]any{ + "schedule_day": 1, + "depreciation_percent": 10.0, + "pullet_cost_day_n": 500.0, + }, + }, + }, + }, + }, + }, + 31: { + ProjectFlockKandangID: 31, + KandangID: 301, + KandangName: "Kandang C2", + Components: []approvalService.HppV2Component{ + { + Code: "DEPRECIATION", + Parts: []approvalService.HppV2ComponentPart{ + { + Code: "normal_transfer", + Total: 100, + Details: map[string]any{ + "schedule_day": 2, + "depreciation_percent": 10.0, + "pullet_cost_day_n": 1000.0, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{3}, map[uint]string{3: "Farm C"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].DepreciationValue != 150 { + t.Fatalf("expected depreciation value 150, got %v", rows[0].DepreciationValue) + } + if rows[0].PulletCostDayNTotal != 1500 { + t.Fatalf("expected pullet cost day n 1500, got %v", rows[0].PulletCostDayNTotal) + } + assertFloatEqual(t, rows[0].DepreciationPercentEffective, 10) + + components := decodeDepreciationComponents(t, rows[0].Components) + if components.KandangCount != 2 { + t.Fatalf("expected kandang_count 2, got %d", components.KandangCount) + } +} + +func TestComputeExpenseDepreciationSnapshots_ZeroWhenDepreciationMissing(t *testing.T) { + periodDate := mustJakartaDate(t, "2026-06-05") + svc := &repportService{ + HppCostRepo: &hppCostRepoMock{ + kandangIDsByFarm: map[uint][]uint{ + 4: {40}, + }, + }, + HppV2Svc: &hppV2ServiceMock{ + breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{ + 40: { + ProjectFlockKandangID: 40, + KandangID: 400, + KandangName: "Kandang D", + Components: []approvalService.HppV2Component{ + {Code: "PAKAN", Total: 123}, + }, + }, + }, + }, + } + + rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{4}, map[uint]string{4: "Farm D"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].DepreciationValue != 0 || rows[0].PulletCostDayNTotal != 0 || rows[0].DepreciationPercentEffective != 0 { + t.Fatalf("expected zero snapshot values, got %+v", rows[0]) + } + components := decodeDepreciationComponents(t, rows[0].Components) + if components.KandangCount != 0 || len(components.Kandang) != 0 { + t.Fatalf("expected empty components, got %+v", components) + } +} + +func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDate(t *testing.T) { + repo := &expenseDepreciationRepoMock{ + manualInputs: []repportRepo.FarmDepreciationManualInputRow{ + { + Id: 123, + ProjectFlockID: 99, + FarmName: "Farm Z", + TotalCost: 1000, + CutoverDate: mustJakartaDate(t, "2026-06-01"), + }, + }, + } + + svc := &repportService{ + Validate: validator.New(), + ExpenseDepreciationRepo: repo, + } + + reqPayload := &validation.ExpenseDepreciationManualInputUpsert{ + ProjectFlockID: 99, + TotalCost: 1000, + CutoverDate: "2026-06-01", + } + + app := fiber.New() + var response *dto.ExpenseDepreciationManualInputRowDTO + app.Put("/", func(c *fiber.Ctx) error { + result, err := svc.UpsertExpenseDepreciationManualInput(c, reqPayload) + if err != nil { + return err + } + response = result + return c.SendStatus(fiber.StatusOK) + }) + + httpResp, err := app.Test(httptest.NewRequest(http.MethodPut, "/", nil)) + if err != nil { + t.Fatalf("expected no app error, got %v", err) + } + if httpResp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status %d, got %d", fiber.StatusOK, httpResp.StatusCode) + } + if !repo.deleteCalled { + t.Fatal("expected DeleteSnapshotsFromDate to be called") + } + if len(repo.deleteFarmIDs) != 1 || repo.deleteFarmIDs[0] != 99 { + t.Fatalf("expected delete farm ids [99], got %v", repo.deleteFarmIDs) + } + if repo.deleteDate.Format("2006-01-02") != "2026-06-01" { + t.Fatalf("expected delete date 2026-06-01, got %s", repo.deleteDate.Format("2006-01-02")) + } + if response == nil { + t.Fatal("expected response") + } + if response.FarmName != "Farm Z" { + t.Fatalf("expected farm name Farm Z, got %s", response.FarmName) + } +} + +func decodeDepreciationComponents(t *testing.T, raw []byte) depreciationFarmComponents { + t.Helper() + var out depreciationFarmComponents + if len(raw) == 0 { + return out + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("failed to decode components: %v", err) + } + return out +} + +func mustJakartaDate(t *testing.T, raw string) time.Time { + t.Helper() + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading timezone: %v", err) + } + value, err := time.ParseInLocation("2006-01-02", raw, location) + if err != nil { + t.Fatalf("failed parsing date %q: %v", raw, err) + } + return value +} + +func assertFloatEqual(t *testing.T, got float64, want float64) { + t.Helper() + const epsilon = 0.000001 + if got > want+epsilon || got < want-epsilon { + t.Fatalf("expected %.6f, got %.6f", want, got) + } +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index edd90f04..3ffa1e09 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -416,6 +416,13 @@ func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, re if err := s.ExpenseDepreciationRepo.UpsertManualInput(ctx.Context(), &row); err != nil { return nil, err } + if err := s.ExpenseDepreciationRepo.DeleteSnapshotsFromDate( + ctx.Context(), + cutoverDate, + []uint{row.ProjectFlockId}, + ); err != nil { + return nil, err + } response := &dto.ExpenseDepreciationManualInputRowDTO{ ID: int64(row.Id), @@ -456,6 +463,11 @@ type depreciationKandangComponent struct { TransferQty float64 `json:"transfer_qty"` PulletCostDayN float64 `json:"pullet_cost_day_n"` DepreciationValue float64 `json:"depreciation_value"` + DepreciationSource string `json:"depreciation_source,omitempty"` + ManualInputID *uint `json:"manual_input_id,omitempty"` + CutoverDate string `json:"cutover_date,omitempty"` + OriginDate string `json:"origin_date,omitempty"` + StartScheduleDay *int `json:"start_schedule_day,omitempty"` } type depreciationFarmComponents struct { @@ -469,124 +481,98 @@ func (s *repportService) computeExpenseDepreciationSnapshots( farmIDs []uint, farmNameByID map[uint]string, ) ([]entity.FarmDepreciationSnapshot, error) { + _ = farmNameByID + if len(farmIDs) == 0 { return []entity.FarmDepreciationSnapshot{}, nil } - - inputRows, err := s.ExpenseDepreciationRepo.GetLatestTransferInputsByFarms(ctx, periodDate, farmIDs) - if err != nil { - return nil, err + if s.HppCostRepo == nil { + return nil, errors.New("hpp cost repository is not configured") } - - groupedByFarm := make(map[uint][]repportRepo.FarmDepreciationLatestTransferRow, len(farmIDs)) - houseTypeSet := make(map[string]struct{}) - maxDay := 0 - - for _, row := range inputRows { - groupedByFarm[row.ProjectFlockID] = append(groupedByFarm[row.ProjectFlockID], row) - dayN := approvalService.DepreciationScheduleDay(row.TransferDate, periodDate, valueOrEmptyString(row.HouseType)) - if dayN > maxDay { - maxDay = dayN - } - houseType := approvalService.NormalizeDepreciationHouseType(valueOrEmptyString(row.HouseType)) - if houseType != "" { - houseTypeSet[houseType] = struct{}{} - } + if s.HppV2Svc == nil { + return nil, errors.New("hpp v2 service is not configured") } - houseTypes := make([]string, 0, len(houseTypeSet)) - for houseType := range houseTypeSet { - houseTypes = append(houseTypes, houseType) - } - sort.Strings(houseTypes) - - percentByHouseType, err := s.ExpenseDepreciationRepo.GetDepreciationPercents(ctx, houseTypes, maxDay) - if err != nil { - return nil, err - } - - type sourceCostCacheItem struct { - totalDepCost float64 - } - sourceCostCache := make(map[string]sourceCostCacheItem) - sourcePopulationCache := make(map[uint]float64) - result := make([]entity.FarmDepreciationSnapshot, 0, len(farmIDs)) for _, farmID := range farmIDs { - farmRows := groupedByFarm[farmID] + kandangIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, farmID) + if err != nil { + return nil, err + } + components := depreciationFarmComponents{ - KandangCount: len(farmRows), - Kandang: make([]depreciationKandangComponent, 0, len(farmRows)), + Kandang: make([]depreciationKandangComponent, 0, len(kandangIDs)), } totalDepreciationValue := 0.0 totalPulletCostDayN := 0.0 - for _, row := range farmRows { - dayN := approvalService.DepreciationScheduleDay(row.TransferDate, periodDate, valueOrEmptyString(row.HouseType)) - houseType := approvalService.NormalizeDepreciationHouseType(valueOrEmptyString(row.HouseType)) + for _, kandangID := range kandangIDs { + breakdown, err := s.HppV2Svc.CalculateHppBreakdown(kandangID, &periodDate) + if err != nil { + return nil, err + } + if breakdown == nil { + continue + } - transferDateKey := row.TransferDate.Format("2006-01-02") - cacheKey := fmt.Sprintf("%d|%s", row.SourceProjectFlockID, transferDateKey) - cached, exists := sourceCostCache[cacheKey] - if !exists { - endOfDay := row.TransferDate.Add(24 * time.Hour) - sourceDepCost, calcErr := s.HppSvc.GetTotalDepresiasiFlockGrowing(row.SourceProjectFlockID, &endOfDay) - if calcErr != nil { - return nil, calcErr + depreciationComponent := hppV2FindDepreciationComponent(breakdown) + if depreciationComponent == nil { + continue + } + + for _, part := range depreciationComponent.Parts { + if part.Total <= 0 { + continue } - cached = sourceCostCacheItem{totalDepCost: sourceDepCost} - sourceCostCache[cacheKey] = cached - } - sourcePopulation, popExists := sourcePopulationCache[row.SourceProjectFlockID] - if !popExists { - if s.HppCostRepo == nil { - sourcePopulation = 0 - } else { - kandangIDs, idsErr := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, row.SourceProjectFlockID) - if idsErr != nil { - return nil, idsErr - } - population, popErr := s.HppCostRepo.GetTotalPopulation(ctx, kandangIDs) - if popErr != nil { - return nil, popErr - } - sourcePopulation = population + houseType := approvalService.NormalizeDepreciationHouseType(breakdown.HouseType) + component := depreciationKandangComponent{ + ProjectFlockKandangID: breakdown.ProjectFlockKandangID, + KandangID: breakdown.KandangID, + KandangName: breakdown.KandangName, + SourceProjectFlockID: hppV2DetailUint(part.Details, "source_project_flock_id"), + HouseType: houseType, + DayN: hppV2DetailInt(part.Details, "schedule_day"), + DepreciationPercent: hppV2DetailFloat(part.Details, "depreciation_percent"), + PulletCostDayN: hppV2DetailFloat(part.Details, "pullet_cost_day_n"), + DepreciationValue: part.Total, + DepreciationSource: part.Code, + OriginDate: hppV2DetailString(part.Details, "origin_date"), } - sourcePopulationCache[row.SourceProjectFlockID] = sourcePopulation + + if component.HouseType == "" { + component.HouseType = approvalService.NormalizeDepreciationHouseType(hppV2DetailString(part.Details, "house_type")) + } + + if ref := hppV2FindReference(part.References, "laying_transfer"); ref != nil { + component.TransferID = ref.ID + component.TransferDate = ref.Date + component.TransferQty = ref.Qty + } + + if part.Code == "manual_cutover" { + if startDay := hppV2DetailInt(part.Details, "start_schedule_day"); startDay > 0 { + component.StartScheduleDay = &startDay + } + component.CutoverDate = hppV2DetailString(part.Details, "cutover_date") + if manualID := hppV2DetailUint(part.Details, "manual_input_id"); manualID > 0 { + component.ManualInputID = &manualID + } + if component.ManualInputID == nil { + if ref := hppV2FindReference(part.References, "farm_depreciation_manual_input"); ref != nil && ref.ID > 0 { + manualID := ref.ID + component.ManualInputID = &manualID + } + } + } + + totalPulletCostDayN += component.PulletCostDayN + totalDepreciationValue += component.DepreciationValue + components.Kandang = append(components.Kandang, component) } - - initialPulletCost := 0.0 - if sourcePopulation > 0 { - initialPulletCost = (cached.totalDepCost * row.TransferQty) / sourcePopulation - } - - pulletCostDayN, depreciationValue, depreciationPercent := approvalService.CalculateDepreciationAtDayN( - initialPulletCost, - dayN, - houseType, - percentByHouseType, - ) - - totalPulletCostDayN += pulletCostDayN - totalDepreciationValue += depreciationValue - - components.Kandang = append(components.Kandang, depreciationKandangComponent{ - ProjectFlockKandangID: row.ProjectFlockKandangID, - KandangID: row.KandangID, - KandangName: row.KandangName, - TransferID: row.TransferID, - TransferDate: row.TransferDate.Format("2006-01-02"), - SourceProjectFlockID: row.SourceProjectFlockID, - HouseType: houseType, - DayN: dayN, - DepreciationPercent: depreciationPercent, - TransferQty: row.TransferQty, - PulletCostDayN: pulletCostDayN, - DepreciationValue: depreciationValue, - }) } + components.KandangCount = len(components.Kandang) effectivePercent := approvalService.CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN) componentsJSON, marshalErr := json.Marshal(components) @@ -607,6 +593,106 @@ func (s *repportService) computeExpenseDepreciationSnapshots( return result, nil } +func hppV2FindDepreciationComponent(breakdown *approvalService.HppV2Breakdown) *approvalService.HppV2Component { + if breakdown == nil { + return nil + } + for idx := range breakdown.Components { + if breakdown.Components[idx].Code == "DEPRECIATION" { + return &breakdown.Components[idx] + } + } + return nil +} + +func hppV2FindReference(references []approvalService.HppV2Reference, refType string) *approvalService.HppV2Reference { + if refType == "" { + return nil + } + for idx := range references { + if references[idx].Type == refType { + return &references[idx] + } + } + return nil +} + +func hppV2DetailFloat(details map[string]any, key string) float64 { + if details == nil || key == "" { + return 0 + } + + raw, exists := details[key] + if !exists || raw == nil { + return 0 + } + + switch value := raw.(type) { + case float64: + return value + case float32: + return float64(value) + case int: + return float64(value) + case int8: + return float64(value) + case int16: + return float64(value) + case int32: + return float64(value) + case int64: + return float64(value) + case uint: + return float64(value) + case uint8: + return float64(value) + case uint16: + return float64(value) + case uint32: + return float64(value) + case uint64: + return float64(value) + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return 0 + } + return parsed + default: + return 0 + } +} + +func hppV2DetailInt(details map[string]any, key string) int { + return int(math.Round(hppV2DetailFloat(details, key))) +} + +func hppV2DetailUint(details map[string]any, key string) uint { + value := hppV2DetailInt(details, key) + if value < 0 { + return 0 + } + return uint(value) +} + +func hppV2DetailString(details map[string]any, key string) string { + if details == nil || key == "" { + return "" + } + raw, exists := details[key] + if !exists || raw == nil { + return "" + } + switch value := raw.(type) { + case string: + return value + case time.Time: + return value.Format("2006-01-02") + default: + return fmt.Sprintf("%v", value) + } +} + func parseSnapshotComponents(raw []byte) any { if len(raw) == 0 { return map[string]any{} @@ -2280,13 +2366,15 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } if hppCost != nil { eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir - eggHpp = hppCost.Estimation.HargaKg + // eggHpp = hppCost.Estimation.HargaKg + eggHpp = hppCost.Real.HargaKg eggTotalPiecesFloat = hppCost.Estimation.Butir eggWeightFloat = hppCost.Estimation.Kg if eggTotalPiecesFloat > 0 { avgWeight = eggWeightFloat / eggTotalPiecesFloat } - eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining + // eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining + eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg } } if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { From 45bed3b76597586dd7e46152911ee4eb4c726cd5 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 19 Apr 2026 21:13:48 +0700 Subject: [PATCH 7/8] add adjust migration --- .../main.go | 632 ++++++++++++++++++ .../main_test.go | 563 ++++++++++++++++ cmd/import-kandang-house-types/main.go | 602 +++++++++++++++++ cmd/import-kandang-house-types/main_test.go | 280 ++++++++ .../farm_depreciation_manual_inputs_import.md | 76 +++ .../farm_depreciation_manual_inputs.xlsx | Bin 0 -> 8668 bytes docs/templates/kandang_house_type.xlsx | Bin 0 -> 31880 bytes .../~$farm_depreciation_manual_inputs.xlsx | Bin 0 -> 165 bytes docs/templates/~$kandang_house_type.xlsx | Bin 0 -> 165 bytes ...eate_farm_depreciation_snapshots.down.sql} | 0 ...create_farm_depreciation_snapshots.up.sql} | 0 ..._farm_depreciation_manual_inputs.down.sql} | 0 ...te_farm_depreciation_manual_inputs.up.sql} | 0 ...farm_depreciation_manual_inputs..down.sql} | 0 ...o_farm_depreciation_manual_inputs..up.sql} | 0 15 files changed, 2153 insertions(+) create mode 100644 cmd/import-farm-depreciation-manual-inputs/main.go create mode 100644 cmd/import-farm-depreciation-manual-inputs/main_test.go create mode 100644 cmd/import-kandang-house-types/main.go create mode 100644 cmd/import-kandang-house-types/main_test.go create mode 100644 docs/farm_depreciation_manual_inputs_import.md create mode 100644 docs/templates/farm_depreciation_manual_inputs.xlsx create mode 100644 docs/templates/kandang_house_type.xlsx create mode 100644 docs/templates/~$farm_depreciation_manual_inputs.xlsx create mode 100644 docs/templates/~$kandang_house_type.xlsx rename internal/database/migrations/{20260416090000_create_farm_depreciation_snapshots.down.sql => 20260419134846_create_farm_depreciation_snapshots.down.sql} (100%) rename internal/database/migrations/{20260416090000_create_farm_depreciation_snapshots.up.sql => 20260419134846_create_farm_depreciation_snapshots.up.sql} (100%) rename internal/database/migrations/{20260417110000_create_farm_depreciation_manual_inputs.down.sql => 20260419135003_create_farm_depreciation_manual_inputs.down.sql} (100%) rename internal/database/migrations/{20260417110000_create_farm_depreciation_manual_inputs.up.sql => 20260419135003_create_farm_depreciation_manual_inputs.up.sql} (100%) rename internal/database/migrations/{20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql => 20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..down.sql} (100%) rename internal/database/migrations/{20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql => 20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..up.sql} (100%) diff --git a/cmd/import-farm-depreciation-manual-inputs/main.go b/cmd/import-farm-depreciation-manual-inputs/main.go new file mode 100644 index 00000000..51c0fe66 --- /dev/null +++ b/cmd/import-farm-depreciation-manual-inputs/main.go @@ -0,0 +1,632 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +const dateLayout = "2006-01-02" + +type importOptions struct { + FilePath string + Sheet string + Apply bool +} + +type headerIndexes struct { + ProjectFlockID int + TotalCost int + CutoverDate int + Note int +} + +type manualInputImportRow struct { + RowNumber int + ProjectFlockID uint + TotalCost float64 + CutoverDate time.Time + Note *string +} + +type validationIssue struct { + Row int + Field string + Message string +} + +func (i validationIssue) Error() string { + if i.Row > 0 { + return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message) + } + return fmt.Sprintf("field=%s message=%s", i.Field, i.Message) +} + +type farmResolver interface { + ResolveActiveLayingFarms(ctx context.Context, projectFlockIDs []uint) (map[uint]string, error) +} + +type dbFarmResolver struct { + db *gorm.DB +} + +type manualInputStore interface { + UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error + DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error +} + +type txRunner interface { + InTx(ctx context.Context, fn func(store manualInputStore) error) error +} + +type dbTxRunner struct { + db *gorm.DB +} + +type expenseDepreciationStore struct { + repo repportRepo.ExpenseDepreciationRepository +} + +type farmIdentityRow struct { + ID uint `gorm:"column:id"` + FarmName string `gorm:"column:farm_name"` +} + +func main() { + var opts importOptions + flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)") + flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)") + flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run") + flag.Parse() + + opts.FilePath = strings.TrimSpace(opts.FilePath) + opts.Sheet = strings.TrimSpace(opts.Sheet) + + if opts.FilePath == "" { + log.Fatal("--file is required") + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + log.Fatalf("failed to load timezone Asia/Jakarta: %v", err) + } + + sheetName, rows, parseIssues, err := parseManualInputFile(opts.FilePath, opts.Sheet, location) + if err != nil { + log.Fatalf("failed reading excel: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + resolver := dbFarmResolver{db: db} + + farmNameByID, err := resolver.ResolveActiveLayingFarms(ctx, collectProjectFlockIDs(rows)) + if err != nil { + log.Fatalf("failed validating project_flock_id against project_flocks: %v", err) + } + + issues := append([]validationIssue{}, parseIssues...) + issues = append(issues, buildMissingFarmIssues(rows, farmNameByID)...) + sortValidationIssues(issues) + + fmt.Printf("Mode: %s\n", modeLabel(opts.Apply)) + fmt.Printf("File: %s\n", opts.FilePath) + fmt.Printf("Sheet: %s\n", sheetName) + fmt.Printf("Rows parsed: %d\n", len(rows)) + fmt.Printf("Rows invalid: %d\n", len(issues)) + fmt.Println() + + if len(rows) > 0 { + printPlanRows(rows, farmNameByID) + fmt.Println() + } + + if len(issues) > 0 { + fmt.Println("Validation errors:") + for _, issue := range issues { + fmt.Printf("ERROR %s\n", issue.Error()) + } + fmt.Println() + fmt.Printf("Summary: planned=%d applied=0 failed=%d\n", len(rows), len(issues)) + os.Exit(1) + } + + if !opts.Apply { + fmt.Printf("Summary: planned=%d applied=0 failed=0\n", len(rows)) + return + } + + if len(rows) == 0 { + fmt.Println("Summary: planned=0 applied=0 failed=0") + return + } + + if err := applyIfRequested(ctx, true, dbTxRunner{db: db}, rows); err != nil { + log.Fatalf("apply failed: %v", err) + } + + for _, row := range rows { + fmt.Printf( + "DONE row=%d project_flock_id=%d cutover_date=%s\n", + row.RowNumber, + row.ProjectFlockID, + row.CutoverDate.In(location).Format(dateLayout), + ) + } + + fmt.Println() + fmt.Printf("Summary: planned=%d applied=%d failed=0\n", len(rows), len(rows)) +} + +func parseManualInputFile( + filePath string, + requestedSheet string, + location *time.Location, +) (string, []manualInputImportRow, []validationIssue, error) { + workbook, err := excelize.OpenFile(filePath) + if err != nil { + return "", nil, nil, err + } + defer func() { + _ = workbook.Close() + }() + + sheetName, err := resolveSheetName(workbook, requestedSheet) + if err != nil { + return "", nil, nil, err + } + + allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true}) + if err != nil { + return "", nil, nil, err + } + if len(allRows) == 0 { + return sheetName, nil, []validationIssue{ + {Field: "header", Message: "sheet is empty"}, + }, nil + } + + indexes, headerIssues := parseHeaderIndexes(allRows[0]) + if len(headerIssues) > 0 { + return sheetName, nil, headerIssues, nil + } + + rows := make([]manualInputImportRow, 0, len(allRows)-1) + issues := make([]validationIssue, 0) + seenProjectFlockIDs := make(map[uint]int) + + for idx := 1; idx < len(allRows); idx++ { + rowNumber := idx + 1 + rawRow := allRows[idx] + + if isRowEmpty(rawRow) { + continue + } + + parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, location, seenProjectFlockIDs) + if len(rowIssues) > 0 { + issues = append(issues, rowIssues...) + continue + } + + rows = append(rows, *parsed) + } + + if len(rows) == 0 && len(issues) == 0 { + issues = append(issues, validationIssue{ + Field: "rows", + Message: "no data rows found", + }) + } + + return sheetName, rows, issues, nil +} + +func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) { + if workbook == nil { + return "", fmt.Errorf("workbook is nil") + } + + sheets := workbook.GetSheetList() + if len(sheets) == 0 { + return "", fmt.Errorf("workbook has no sheets") + } + + if requestedSheet == "" { + return sheets[0], nil + } + + for _, sheet := range sheets { + if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) { + return sheet, nil + } + } + + return "", fmt.Errorf("sheet %q not found", requestedSheet) +} + +func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) { + indexes := headerIndexes{ + ProjectFlockID: -1, + TotalCost: -1, + CutoverDate: -1, + Note: -1, + } + issues := make([]validationIssue, 0) + + for idx, raw := range headerRow { + header := normalizeHeader(raw) + if header == "" { + continue + } + + switch header { + case "project_flock_id": + if indexes.ProjectFlockID >= 0 { + issues = append(issues, validationIssue{ + Field: "header", + Message: "duplicate header project_flock_id", + }) + } + indexes.ProjectFlockID = idx + case "total_cost": + if indexes.TotalCost >= 0 { + issues = append(issues, validationIssue{ + Field: "header", + Message: "duplicate header total_cost", + }) + } + indexes.TotalCost = idx + case "cutover_date": + if indexes.CutoverDate >= 0 { + issues = append(issues, validationIssue{ + Field: "header", + Message: "duplicate header cutover_date", + }) + } + indexes.CutoverDate = idx + case "note": + if indexes.Note >= 0 { + issues = append(issues, validationIssue{ + Field: "header", + Message: "duplicate header note", + }) + } + indexes.Note = idx + } + } + + if indexes.ProjectFlockID < 0 { + issues = append(issues, validationIssue{ + Field: "project_flock_id", + Message: "required header is missing", + }) + } + if indexes.TotalCost < 0 { + issues = append(issues, validationIssue{ + Field: "total_cost", + Message: "required header is missing", + }) + } + if indexes.CutoverDate < 0 { + issues = append(issues, validationIssue{ + Field: "cutover_date", + Message: "required header is missing", + }) + } + + return indexes, issues +} + +func parseDataRow( + rawRow []string, + rowNumber int, + indexes headerIndexes, + location *time.Location, + seenProjectFlockIDs map[uint]int, +) (*manualInputImportRow, []validationIssue) { + issues := make([]validationIssue, 0) + + projectFlockIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.ProjectFlockID)) + projectFlockID, err := parsePositiveUint(projectFlockIDRaw) + if err != nil { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "project_flock_id", + Message: err.Error(), + }) + } + + totalCostRaw := strings.TrimSpace(cellValue(rawRow, indexes.TotalCost)) + totalCost, err := parseNonNegativeFloat(totalCostRaw) + if err != nil { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "total_cost", + Message: err.Error(), + }) + } + + cutoverDateRaw := strings.TrimSpace(cellValue(rawRow, indexes.CutoverDate)) + cutoverDate, err := parseDateOnlyInLocation(cutoverDateRaw, location) + if err != nil { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "cutover_date", + Message: err.Error(), + }) + } + + var note *string + noteRaw := strings.TrimSpace(cellValue(rawRow, indexes.Note)) + if noteRaw != "" { + if len([]rune(noteRaw)) > 1000 { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "note", + Message: "must have at most 1000 characters", + }) + } else { + note = ¬eRaw + } + } + + if projectFlockID > 0 { + if previousRow, exists := seenProjectFlockIDs[projectFlockID]; exists { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "project_flock_id", + Message: fmt.Sprintf("duplicate value %d (already used in row %d)", projectFlockID, previousRow), + }) + } else { + seenProjectFlockIDs[projectFlockID] = rowNumber + } + } + + if len(issues) > 0 { + return nil, issues + } + + return &manualInputImportRow{ + RowNumber: rowNumber, + ProjectFlockID: projectFlockID, + TotalCost: totalCost, + CutoverDate: cutoverDate, + Note: note, + }, nil +} + +func parsePositiveUint(raw string) (uint, error) { + if raw == "" { + return 0, fmt.Errorf("is required") + } + + uintValue, err := strconv.ParseUint(raw, 10, 64) + if err == nil { + if uintValue == 0 { + return 0, fmt.Errorf("must be greater than 0") + } + return uint(uintValue), nil + } + + floatValue, floatErr := strconv.ParseFloat(raw, 64) + if floatErr != nil { + return 0, fmt.Errorf("must be a positive integer") + } + if floatValue <= 0 { + return 0, fmt.Errorf("must be greater than 0") + } + if floatValue != float64(uint(floatValue)) { + return 0, fmt.Errorf("must be a positive integer") + } + + return uint(floatValue), nil +} + +func parseNonNegativeFloat(raw string) (float64, error) { + if raw == "" { + return 0, fmt.Errorf("is required") + } + + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, fmt.Errorf("must be numeric") + } + if value < 0 { + return 0, fmt.Errorf("must be greater than or equal to 0") + } + + return value, nil +} + +func parseDateOnlyInLocation(raw string, location *time.Location) (time.Time, error) { + if raw == "" { + return time.Time{}, fmt.Errorf("is required") + } + value, err := time.ParseInLocation(dateLayout, raw, location) + if err != nil { + return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD") + } + return value, nil +} + +func isRowEmpty(row []string) bool { + for _, cell := range row { + if strings.TrimSpace(cell) != "" { + return false + } + } + return true +} + +func normalizeHeader(raw string) string { + return strings.ToLower(strings.TrimSpace(raw)) +} + +func cellValue(row []string, index int) string { + if index < 0 || index >= len(row) { + return "" + } + return row[index] +} + +func collectProjectFlockIDs(rows []manualInputImportRow) []uint { + ids := make([]uint, 0, len(rows)) + seen := make(map[uint]struct{}, len(rows)) + for _, row := range rows { + if row.ProjectFlockID == 0 { + continue + } + if _, exists := seen[row.ProjectFlockID]; exists { + continue + } + seen[row.ProjectFlockID] = struct{}{} + ids = append(ids, row.ProjectFlockID) + } + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + return ids +} + +func (r dbFarmResolver) ResolveActiveLayingFarms( + ctx context.Context, + projectFlockIDs []uint, +) (map[uint]string, error) { + result := make(map[uint]string) + if len(projectFlockIDs) == 0 { + return result, nil + } + + rows := make([]farmIdentityRow, 0, len(projectFlockIDs)) + if err := r.db.WithContext(ctx). + Table("project_flocks"). + Select("id, flock_name AS farm_name"). + Where("id IN ?", projectFlockIDs). + Where("deleted_at IS NULL"). + Where("category = ?", utils.ProjectFlockCategoryLaying). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ID] = row.FarmName + } + + return result, nil +} + +func buildMissingFarmIssues(rows []manualInputImportRow, farmNameByID map[uint]string) []validationIssue { + issues := make([]validationIssue, 0) + for _, row := range rows { + if _, exists := farmNameByID[row.ProjectFlockID]; exists { + continue + } + issues = append(issues, validationIssue{ + Row: row.RowNumber, + Field: "project_flock_id", + Message: fmt.Sprintf("value %d must reference an active LAYING project_flock", row.ProjectFlockID), + }) + } + return issues +} + +func printPlanRows(rows []manualInputImportRow, farmNameByID map[uint]string) { + for _, row := range rows { + farmName := farmNameByID[row.ProjectFlockID] + fmt.Printf( + "PLAN row=%d project_flock_id=%d farm_name=%q total_cost=%.3f cutover_date=%s note=%q\n", + row.RowNumber, + row.ProjectFlockID, + farmName, + row.TotalCost, + row.CutoverDate.Format(dateLayout), + derefString(row.Note), + ) + } +} + +func sortValidationIssues(issues []validationIssue) { + sort.Slice(issues, func(i, j int) bool { + if issues[i].Row == issues[j].Row { + if issues[i].Field == issues[j].Field { + return issues[i].Message < issues[j].Message + } + return issues[i].Field < issues[j].Field + } + return issues[i].Row < issues[j].Row + }) +} + +func applyIfRequested(ctx context.Context, apply bool, runner txRunner, rows []manualInputImportRow) error { + if !apply || len(rows) == 0 { + return nil + } + return applyImportRows(ctx, runner, rows) +} + +func applyImportRows(ctx context.Context, runner txRunner, rows []manualInputImportRow) error { + return runner.InTx(ctx, func(store manualInputStore) error { + for _, row := range rows { + payload := entity.FarmDepreciationManualInput{ + ProjectFlockId: row.ProjectFlockID, + TotalCost: row.TotalCost, + CutoverDate: row.CutoverDate, + Note: row.Note, + } + + if err := store.UpsertManualInput(ctx, &payload); err != nil { + return fmt.Errorf("row %d project_flock_id=%d upsert failed: %w", row.RowNumber, row.ProjectFlockID, err) + } + + if err := store.DeleteSnapshotsFromDate(ctx, row.CutoverDate, []uint{row.ProjectFlockID}); err != nil { + return fmt.Errorf("row %d project_flock_id=%d snapshot invalidation failed: %w", row.RowNumber, row.ProjectFlockID, err) + } + } + return nil + }) +} + +func (r dbTxRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repo := repportRepo.NewExpenseDepreciationRepository(tx) + store := expenseDepreciationStore{repo: repo} + return fn(store) + }) +} + +func (s expenseDepreciationStore) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error { + return s.repo.UpsertManualInput(ctx, row) +} + +func (s expenseDepreciationStore) DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error { + return s.repo.DeleteSnapshotsFromDate(ctx, fromDate, farmIDs) +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/cmd/import-farm-depreciation-manual-inputs/main_test.go b/cmd/import-farm-depreciation-manual-inputs/main_test.go new file mode 100644 index 00000000..0a2d8439 --- /dev/null +++ b/cmd/import-farm-depreciation-manual-inputs/main_test.go @@ -0,0 +1,563 @@ +package main + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/xuri/excelize/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +func TestParseManualInputFile_ValidSingleRow(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "12345.678", "2026-06-01", "manual seed"}, + }, + ) + + location := mustJakartaLocation(t) + sheet, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if sheet != "manual_inputs" { + t.Fatalf("expected selected sheet manual_inputs, got %q", sheet) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].ProjectFlockID != 101 { + t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID) + } + if rows[0].TotalCost != 12345.678 { + t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost) + } + if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" { + t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout)) + } + if rows[0].Note == nil || *rows[0].Note != "manual seed" { + t.Fatalf("expected note manual seed, got %+v", rows[0].Note) + } +} + +func TestParseManualInputFile_ValidMultiRow(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + {"102", "1300.5", "2026-06-02", "second"}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + if rows[0].Note != nil { + t.Fatalf("expected first row note nil, got %+v", rows[0].Note) + } + if rows[1].Note == nil || *rows[1].Note != "second" { + t.Fatalf("expected second row note second, got %+v", rows[1].Note) + } +} + +func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "totalcost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows)) + } + if !hasIssue(issues, 0, "total_cost", "required header is missing") { + t.Fatalf("expected missing total_cost header issue, got %+v", issues) + } +} + +func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"abc", "1200", "2026-06-01", ""}, + {"0", "1300", "2026-06-02", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") { + t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues) + } + if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") { + t.Fatalf("expected project_flock_id >0 issue, got %+v", issues) + } +} + +func TestParseManualInputFile_InvalidTotalCost(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "abc", "2026-06-01", ""}, + {"102", "-1", "2026-06-02", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "total_cost", "must be numeric") { + t.Fatalf("expected total_cost numeric issue, got %+v", issues) + } + if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") { + t.Fatalf("expected total_cost >=0 issue, got %+v", issues) + } +} + +func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "06-01-2026", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") { + t.Fatalf("expected cutover_date format issue, got %+v", issues) + } +} + +func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) { + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + {"101", "1300", "2026-06-02", ""}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows)) + } + if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") { + t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues) + } +} + +func TestParseManualInputFile_NoteValidation(t *testing.T) { + longNote := strings.Repeat("a", 1001) + filePath := createManualInputWorkbook( + t, + "manual_inputs", + []string{"project_flock_id", "total_cost", "cutover_date", "note"}, + [][]string{ + {"101", "1200", "2026-06-01", ""}, + {"102", "1300", "2026-06-02", longNote}, + }, + ) + + location := mustJakartaLocation(t) + _, rows, issues, err := parseManualInputFile(filePath, "", location) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected only first row valid, got %d", len(rows)) + } + if rows[0].Note != nil { + t.Fatalf("expected first row note nil, got %+v", rows[0].Note) + } + if !hasIssue(issues, 3, "note", "at most 1000 characters") { + t.Fatalf("expected note length issue, got %+v", issues) + } +} + +func TestApplyImportRows_Success(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{} + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + { + RowNumber: 3, + ProjectFlockID: 102, + TotalCost: 2000, + CutoverDate: mustDateInLocation(t, "2026-06-02", location), + }, + } + + err := applyImportRows(context.Background(), runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) + } + if len(runner.committedUpserts) != 2 { + t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts)) + } + if len(runner.committedInvalidations) != 2 { + t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations)) + } + if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 { + t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations) + } +} + +func TestApplyImportRows_RollbackOnError(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{ + failUpsertOnProjectFlockID: 102, + } + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + { + RowNumber: 3, + ProjectFlockID: 102, + TotalCost: 2000, + CutoverDate: mustDateInLocation(t, "2026-06-02", location), + }, + } + + err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatal("expected error due to upsert failure") + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) + } + if len(runner.committedUpserts) != 0 { + t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts)) + } + if len(runner.committedInvalidations) != 0 { + t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations)) + } +} + +func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{} + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + + err := applyIfRequested(context.Background(), false, runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 0 { + t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls) + } +} + +func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string { + t.Helper() + + f := excelize.NewFile() + defaultSheet := f.GetSheetName(f.GetActiveSheetIndex()) + if sheetName == "" { + sheetName = defaultSheet + } else if sheetName != defaultSheet { + f.SetSheetName(defaultSheet, sheetName) + } + + for idx, header := range headers { + cell, err := excelize.CoordinatesToCellName(idx+1, 1) + if err != nil { + t.Fatalf("failed resolving header cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, header); err != nil { + t.Fatalf("failed setting header cell: %v", err) + } + } + + for rowIdx, row := range rows { + for colIdx, value := range row { + cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) + if err != nil { + t.Fatalf("failed resolving data cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, value); err != nil { + t.Fatalf("failed setting data cell: %v", err) + } + } + } + + path := filepath.Join(t.TempDir(), "manual_inputs.xlsx") + if err := f.SaveAs(path); err != nil { + t.Fatalf("failed saving workbook: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("failed closing workbook: %v", err) + } + + return path +} + +func mustJakartaLocation(t *testing.T) *time.Location { + t.Helper() + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading Asia/Jakarta location: %v", err) + } + return location +} + +func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time { + t.Helper() + value, err := time.ParseInLocation(dateLayout, raw, location) + if err != nil { + t.Fatalf("failed parsing date %q: %v", raw, err) + } + return value +} + +func hasIssue(issues []validationIssue, row int, field, messageContains string) bool { + for _, issue := range issues { + if issue.Row != row { + continue + } + if issue.Field != field { + continue + } + if strings.Contains(issue.Message, messageContains) { + return true + } + } + return false +} + +type fakeInvalidation struct { + fromDate time.Time + farmIDs []uint +} + +type fakeManualInputStore struct { + failUpsertOnProjectFlockID uint + failDeleteOnProjectFlockID uint + upserts []entity.FarmDepreciationManualInput + invalidations []fakeInvalidation +} + +func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error { + if row == nil { + return nil + } + if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID { + return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId) + } + cloned := *row + s.upserts = append(s.upserts, cloned) + return nil +} + +func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error { + if s.failDeleteOnProjectFlockID > 0 { + for _, farmID := range farmIDs { + if farmID == s.failDeleteOnProjectFlockID { + return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID) + } + } + } + copiedFarmIDs := append([]uint{}, farmIDs...) + s.invalidations = append(s.invalidations, fakeInvalidation{ + fromDate: fromDate, + farmIDs: copiedFarmIDs, + }) + return nil +} + +type fakeTransactionRunner struct { + txCalls int + failUpsertOnProjectFlockID uint + failDeleteOnProjectFlockID uint + committedUpserts []entity.FarmDepreciationManualInput + committedInvalidations []fakeInvalidation +} + +func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error { + r.txCalls++ + + txStore := &fakeManualInputStore{ + failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID, + failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID, + } + + if err := fn(txStore); err != nil { + return err + } + + r.committedUpserts = append(r.committedUpserts, txStore.upserts...) + r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...) + return nil +} + +var _ txRunner = (*fakeTransactionRunner)(nil) +var _ manualInputStore = (*fakeManualInputStore)(nil) + +func TestBuildMissingFarmIssues(t *testing.T) { + location := mustJakartaLocation(t) + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + { + RowNumber: 3, + ProjectFlockID: 102, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + + issues := buildMissingFarmIssues(rows, map[uint]string{ + 101: "Farm A", + }) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %+v", issues) + } + if issues[0].Row != 3 || issues[0].Field != "project_flock_id" { + t.Fatalf("unexpected issue: %+v", issues[0]) + } +} + +func TestApplyImportRows_PropagatesDeleteError(t *testing.T) { + location := mustJakartaLocation(t) + runner := &fakeTransactionRunner{ + failDeleteOnProjectFlockID: 101, + } + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + + err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatal("expected delete failure") + } + if !strings.Contains(err.Error(), "snapshot invalidation failed") { + t.Fatalf("expected snapshot invalidation error message, got %v", err) + } +} + +func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) { + workbook := excelize.NewFile() + defer func() { + _ = workbook.Close() + }() + + _, err := resolveSheetName(workbook, "unknown") + if err == nil { + t.Fatal("expected error when sheet is missing") + } +} + +func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) { + location := mustJakartaLocation(t) + rows := []manualInputImportRow{ + { + RowNumber: 2, + ProjectFlockID: 101, + TotalCost: 1000, + CutoverDate: mustDateInLocation(t, "2026-06-01", location), + }, + } + runner := &errorTxRunner{err: errors.New("tx failed")} + + err := applyIfRequested(context.Background(), true, runner, rows) + if err == nil { + t.Fatal("expected transaction error") + } + if err.Error() != "tx failed" { + t.Fatalf("unexpected error: %v", err) + } +} + +type errorTxRunner struct { + err error +} + +func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error { + return r.err +} diff --git a/cmd/import-kandang-house-types/main.go b/cmd/import-kandang-house-types/main.go new file mode 100644 index 00000000..27e97ffb --- /dev/null +++ b/cmd/import-kandang-house-types/main.go @@ -0,0 +1,602 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type importOptions struct { + FilePath string + Sheet string + Apply bool +} + +type headerIndexes struct { + KandangID int + KandangName int + HouseType int +} + +type kandangHouseTypeImportRow struct { + RowNumber int + KandangID uint + KandangName string + HouseType string +} + +type validationIssue struct { + Row int + Field string + Message string +} + +func (i validationIssue) Error() string { + if i.Row > 0 { + return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message) + } + return fmt.Sprintf("field=%s message=%s", i.Field, i.Message) +} + +type kandangResolver interface { + ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) +} + +type dbKandangResolver struct { + db *gorm.DB +} + +type txRunner interface { + InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error +} + +type dbTxRunner struct { + db *gorm.DB +} + +type kandangHouseTypeStore interface { + UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) + NormalizeNullHouseType(ctx context.Context) (int64, error) +} + +type dbKandangHouseTypeStore struct { + db *gorm.DB +} + +type kandangIdentityRow struct { + ID uint `gorm:"column:id"` + Name string `gorm:"column:name"` +} + +type applyRowResult struct { + RowNumber int + KandangID uint + HouseType string + Changed bool +} + +func main() { + var opts importOptions + flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)") + flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)") + flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run") + flag.Parse() + + opts.FilePath = strings.TrimSpace(opts.FilePath) + opts.Sheet = strings.TrimSpace(opts.Sheet) + + if opts.FilePath == "" { + log.Fatal("--file is required") + } + + sheetName, rows, parseIssues, err := parseKandangHouseTypeFile(opts.FilePath, opts.Sheet) + if err != nil { + log.Fatalf("failed reading excel: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + resolver := dbKandangResolver{db: db} + + kandangNameByID, err := resolver.ResolveActiveKandangs(ctx, collectKandangIDs(rows)) + if err != nil { + log.Fatalf("failed validating kandang_id against kandangs: %v", err) + } + + issues := append([]validationIssue{}, parseIssues...) + issues = append(issues, buildMissingKandangIssues(rows, kandangNameByID)...) + issues = append(issues, buildNameMismatchIssues(rows, kandangNameByID)...) + sortValidationIssues(issues) + + fmt.Printf("Mode: %s\n", modeLabel(opts.Apply)) + fmt.Printf("File: %s\n", opts.FilePath) + fmt.Printf("Sheet: %s\n", sheetName) + fmt.Printf("Rows parsed: %d\n", len(rows)) + fmt.Printf("Rows invalid: %d\n", len(issues)) + fmt.Println() + + if len(rows) > 0 { + printPlanRows(rows, kandangNameByID) + fmt.Println() + } + + if len(issues) > 0 { + fmt.Println("Validation errors:") + for _, issue := range issues { + fmt.Printf("ERROR %s\n", issue.Error()) + } + fmt.Println() + fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=%d\n", len(rows), len(issues)) + os.Exit(1) + } + + if !opts.Apply { + fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=0\n", len(rows)) + return + } + + rowResults, normalizedCount, err := applyImportRows(ctx, dbTxRunner{db: db}, rows) + if err != nil { + log.Fatalf("apply failed: %v", err) + } + + for _, result := range rowResults { + fmt.Printf( + "DONE row=%d kandang_id=%d house_type=%s status=%s\n", + result.RowNumber, + result.KandangID, + result.HouseType, + applyStatus(result.Changed), + ) + } + + appliedCount := countChangedRows(rowResults) + fmt.Println() + fmt.Printf( + "Summary: planned=%d applied=%d normalized_null_to_open_house=%d failed=0\n", + len(rows), + appliedCount, + normalizedCount, + ) +} + +func parseKandangHouseTypeFile( + filePath string, + requestedSheet string, +) (string, []kandangHouseTypeImportRow, []validationIssue, error) { + workbook, err := excelize.OpenFile(filePath) + if err != nil { + return "", nil, nil, err + } + defer func() { + _ = workbook.Close() + }() + + sheetName, err := resolveSheetName(workbook, requestedSheet) + if err != nil { + return "", nil, nil, err + } + + allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true}) + if err != nil { + return "", nil, nil, err + } + if len(allRows) == 0 { + return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil + } + + indexes, headerIssues := parseHeaderIndexes(allRows[0]) + if len(headerIssues) > 0 { + return sheetName, nil, headerIssues, nil + } + + rows := make([]kandangHouseTypeImportRow, 0, len(allRows)-1) + issues := make([]validationIssue, 0) + seenKandangIDs := make(map[uint]int) + + for idx := 1; idx < len(allRows); idx++ { + rowNumber := idx + 1 + rawRow := allRows[idx] + + if isRowEmpty(rawRow) { + continue + } + + parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, seenKandangIDs) + if len(rowIssues) > 0 { + issues = append(issues, rowIssues...) + continue + } + + rows = append(rows, *parsed) + } + + if len(rows) == 0 && len(issues) == 0 { + issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"}) + } + + return sheetName, rows, issues, nil +} + +func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) { + if workbook == nil { + return "", fmt.Errorf("workbook is nil") + } + + sheets := workbook.GetSheetList() + if len(sheets) == 0 { + return "", fmt.Errorf("workbook has no sheets") + } + + if requestedSheet == "" { + return sheets[0], nil + } + + for _, sheet := range sheets { + if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) { + return sheet, nil + } + } + + return "", fmt.Errorf("sheet %q not found", requestedSheet) +} + +func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) { + indexes := headerIndexes{KandangID: -1, KandangName: -1, HouseType: -1} + issues := make([]validationIssue, 0) + + for idx, raw := range headerRow { + header := normalizeHeader(raw) + if header == "" { + continue + } + + switch header { + case "kandang_id": + if indexes.KandangID >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_id"}) + } + indexes.KandangID = idx + case "kandang_name": + if indexes.KandangName >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_name"}) + } + indexes.KandangName = idx + case "house_type", "type_house": + if indexes.HouseType >= 0 { + issues = append(issues, validationIssue{Field: "header", Message: "duplicate header house_type"}) + } + indexes.HouseType = idx + } + } + + if indexes.KandangID < 0 { + issues = append(issues, validationIssue{Field: "kandang_id", Message: "required header is missing"}) + } + if indexes.KandangName < 0 { + issues = append(issues, validationIssue{Field: "kandang_name", Message: "required header is missing"}) + } + if indexes.HouseType < 0 { + issues = append(issues, validationIssue{Field: "house_type", Message: "required header is missing"}) + } + + return indexes, issues +} + +func parseDataRow( + rawRow []string, + rowNumber int, + indexes headerIndexes, + seenKandangIDs map[uint]int, +) (*kandangHouseTypeImportRow, []validationIssue) { + issues := make([]validationIssue, 0) + + kandangIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangID)) + kandangID, err := parsePositiveUint(kandangIDRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_id", Message: err.Error()}) + } + + kandangNameRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangName)) + if kandangNameRaw == "" { + issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_name", Message: "is required"}) + } + + houseTypeRaw := strings.TrimSpace(cellValue(rawRow, indexes.HouseType)) + houseType, err := normalizeHouseType(houseTypeRaw) + if err != nil { + issues = append(issues, validationIssue{Row: rowNumber, Field: "house_type", Message: err.Error()}) + } + + if kandangID > 0 { + if previousRow, exists := seenKandangIDs[kandangID]; exists { + issues = append(issues, validationIssue{ + Row: rowNumber, + Field: "kandang_id", + Message: fmt.Sprintf("duplicate value %d (already used in row %d)", kandangID, previousRow), + }) + } else { + seenKandangIDs[kandangID] = rowNumber + } + } + + if len(issues) > 0 { + return nil, issues + } + + return &kandangHouseTypeImportRow{ + RowNumber: rowNumber, + KandangID: kandangID, + KandangName: kandangNameRaw, + HouseType: houseType, + }, nil +} + +func normalizeHouseType(raw string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if normalized == "" { + return string(utils.HouseTypeOpenHouse), nil + } + + switch normalized { + case string(utils.HouseTypeOpenHouse), string(utils.HouseTypeCloseHouse): + return normalized, nil + default: + return "", fmt.Errorf("must be one of: open_house, close_house (or empty for default open_house)") + } +} + +func parsePositiveUint(raw string) (uint, error) { + if raw == "" { + return 0, fmt.Errorf("is required") + } + + uintValue, err := strconv.ParseUint(raw, 10, 64) + if err == nil { + if uintValue == 0 { + return 0, fmt.Errorf("must be greater than 0") + } + return uint(uintValue), nil + } + + floatValue, floatErr := strconv.ParseFloat(raw, 64) + if floatErr != nil { + return 0, fmt.Errorf("must be a positive integer") + } + if floatValue <= 0 { + return 0, fmt.Errorf("must be greater than 0") + } + if floatValue != float64(uint(floatValue)) { + return 0, fmt.Errorf("must be a positive integer") + } + + return uint(floatValue), nil +} + +func isRowEmpty(row []string) bool { + for _, cell := range row { + if strings.TrimSpace(cell) != "" { + return false + } + } + return true +} + +func normalizeHeader(raw string) string { + return strings.ToLower(strings.TrimSpace(raw)) +} + +func cellValue(row []string, index int) string { + if index < 0 || index >= len(row) { + return "" + } + return row[index] +} + +func collectKandangIDs(rows []kandangHouseTypeImportRow) []uint { + ids := make([]uint, 0, len(rows)) + seen := make(map[uint]struct{}, len(rows)) + for _, row := range rows { + if row.KandangID == 0 { + continue + } + if _, exists := seen[row.KandangID]; exists { + continue + } + seen[row.KandangID] = struct{}{} + ids = append(ids, row.KandangID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func (r dbKandangResolver) ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) { + result := make(map[uint]string) + if len(kandangIDs) == 0 { + return result, nil + } + + rows := make([]kandangIdentityRow, 0, len(kandangIDs)) + if err := r.db.WithContext(ctx). + Table("kandangs"). + Select("id, name"). + Where("id IN ?", kandangIDs). + Where("deleted_at IS NULL"). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + result[row.ID] = row.Name + } + + return result, nil +} + +func buildMissingKandangIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue { + issues := make([]validationIssue, 0) + for _, row := range rows { + if _, exists := kandangNameByID[row.KandangID]; exists { + continue + } + issues = append(issues, validationIssue{ + Row: row.RowNumber, + Field: "kandang_id", + Message: fmt.Sprintf("value %d must reference an active kandang", row.KandangID), + }) + } + return issues +} + +func buildNameMismatchIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue { + issues := make([]validationIssue, 0) + for _, row := range rows { + dbName, exists := kandangNameByID[row.KandangID] + if !exists { + continue + } + if strings.EqualFold(strings.TrimSpace(row.KandangName), strings.TrimSpace(dbName)) { + continue + } + + issues = append(issues, validationIssue{ + Row: row.RowNumber, + Field: "kandang_name", + Message: fmt.Sprintf("value %q does not match kandang_id %d name %q", row.KandangName, row.KandangID, dbName), + }) + } + return issues +} + +func printPlanRows(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) { + for _, row := range rows { + fmt.Printf( + "PLAN row=%d kandang_id=%d kandang_name_file=%q kandang_name_db=%q house_type=%q\n", + row.RowNumber, + row.KandangID, + row.KandangName, + kandangNameByID[row.KandangID], + row.HouseType, + ) + } +} + +func sortValidationIssues(issues []validationIssue) { + sort.Slice(issues, func(i, j int) bool { + if issues[i].Row == issues[j].Row { + if issues[i].Field == issues[j].Field { + return issues[i].Message < issues[j].Message + } + return issues[i].Field < issues[j].Field + } + return issues[i].Row < issues[j].Row + }) +} + +func applyImportRows( + ctx context.Context, + runner txRunner, + rows []kandangHouseTypeImportRow, +) ([]applyRowResult, int64, error) { + results := make([]applyRowResult, 0, len(rows)) + normalizedNullCount := int64(0) + + err := runner.InTx(ctx, func(store kandangHouseTypeStore) error { + for _, row := range rows { + changed, err := store.UpdateKandangHouseType(ctx, row.KandangID, row.HouseType) + if err != nil { + return fmt.Errorf("row %d kandang_id=%d update failed: %w", row.RowNumber, row.KandangID, err) + } + + results = append(results, applyRowResult{ + RowNumber: row.RowNumber, + KandangID: row.KandangID, + HouseType: row.HouseType, + Changed: changed, + }) + } + + normalized, err := store.NormalizeNullHouseType(ctx) + if err != nil { + return fmt.Errorf("normalize null house_type to open_house failed: %w", err) + } + normalizedNullCount = normalized + + return nil + }) + if err != nil { + return nil, 0, err + } + + return results, normalizedNullCount, nil +} + +func (r dbTxRunner) InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return fn(dbKandangHouseTypeStore{db: tx}) + }) +} + +func (s dbKandangHouseTypeStore) UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) { + result := s.db.WithContext(ctx).Exec(` + UPDATE kandangs + SET house_type = ?::house_type_enum, + updated_at = NOW() + WHERE id = ? + AND deleted_at IS NULL + AND house_type IS DISTINCT FROM ?::house_type_enum + `, houseType, kandangID, houseType) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +func (s dbKandangHouseTypeStore) NormalizeNullHouseType(ctx context.Context) (int64, error) { + result := s.db.WithContext(ctx).Exec(` + UPDATE kandangs + SET house_type = 'open_house'::house_type_enum, + updated_at = NOW() + WHERE deleted_at IS NULL + AND house_type IS NULL + `) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func applyStatus(changed bool) string { + if changed { + return "UPDATED" + } + return "UNCHANGED" +} + +func countChangedRows(results []applyRowResult) int { + count := 0 + for _, item := range results { + if item.Changed { + count++ + } + } + return count +} diff --git a/cmd/import-kandang-house-types/main_test.go b/cmd/import-kandang-house-types/main_test.go new file mode 100644 index 00000000..bc733ed9 --- /dev/null +++ b/cmd/import-kandang-house-types/main_test.go @@ -0,0 +1,280 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestParseKandangHouseTypeFile_ValidSingleRowAndDefaultHouseType(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "house_type"}, + [][]string{{"101", "Kandang A1", ""}}, + ) + + sheet, rows, issues, err := parseKandangHouseTypeFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if sheet != "kandang_house_type" { + t.Fatalf("expected sheet kandang_house_type, got %q", sheet) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].KandangID != 101 { + t.Fatalf("expected kandang_id 101, got %d", rows[0].KandangID) + } + if rows[0].KandangName != "Kandang A1" { + t.Fatalf("expected kandang_name Kandang A1, got %q", rows[0].KandangName) + } + if rows[0].HouseType != "open_house" { + t.Fatalf("expected default house_type open_house, got %q", rows[0].HouseType) + } +} + +func TestParseKandangHouseTypeFile_TypeHouseHeaderAlias(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "type_house"}, + [][]string{{"101", "Kandang A1", "close_house"}}, + ) + + _, rows, issues, err := parseKandangHouseTypeFile(filePath, "kandang_house_type") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues, got %+v", issues) + } + if len(rows) != 1 || rows[0].HouseType != "close_house" { + t.Fatalf("expected parsed close_house row, got %+v", rows) + } +} + +func TestParseKandangHouseTypeFile_InvalidHouseType(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "house_type"}, + [][]string{{"101", "Kandang A1", "semi_house"}}, + ) + + _, rows, issues, err := parseKandangHouseTypeFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no valid rows, got %d", len(rows)) + } + if !hasIssue(issues, 2, "house_type", "must be one of") { + t.Fatalf("expected invalid house_type issue, got %+v", issues) + } +} + +func TestParseKandangHouseTypeFile_DuplicateKandangID(t *testing.T) { + filePath := createWorkbook( + t, + "kandang_house_type", + []string{"kandang_id", "kandang_name", "house_type"}, + [][]string{ + {"101", "Kandang A1", "open_house"}, + {"101", "Kandang A2", "close_house"}, + }, + ) + + _, rows, issues, err := parseKandangHouseTypeFile(filePath, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected first row valid and second invalid, got %d", len(rows)) + } + if !hasIssue(issues, 3, "kandang_id", "duplicate value 101") { + t.Fatalf("expected duplicate kandang_id issue, got %+v", issues) + } +} + +func TestBuildNameMismatchIssues(t *testing.T) { + rows := []kandangHouseTypeImportRow{{ + RowNumber: 2, + KandangID: 10, + KandangName: "Kandang Salah", + HouseType: "open_house", + }} + + issues := buildNameMismatchIssues(rows, map[uint]string{10: "Kandang Benar"}) + if !hasIssue(issues, 2, "kandang_name", "does not match") { + t.Fatalf("expected name mismatch issue, got %+v", issues) + } +} + +func TestApplyImportRows_Success(t *testing.T) { + store := &fakeStore{ + changedByID: map[uint]bool{101: true, 102: false}, + normalizeResult: 3, + } + runner := &fakeTransactionRunner{store: store} + + rows := []kandangHouseTypeImportRow{ + {RowNumber: 2, KandangID: 101, HouseType: "open_house"}, + {RowNumber: 3, KandangID: 102, HouseType: "close_house"}, + } + + results, normalized, err := applyImportRows(context.Background(), runner, rows) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if runner.txCalls != 1 { + t.Fatalf("expected 1 tx call, got %d", runner.txCalls) + } + if len(results) != 2 { + t.Fatalf("expected 2 row results, got %d", len(results)) + } + if normalized != 3 { + t.Fatalf("expected normalized count 3, got %d", normalized) + } + if !results[0].Changed || results[1].Changed { + t.Fatalf("unexpected changed flags: %+v", results) + } + if len(store.updateCalls) != 2 { + t.Fatalf("expected 2 update calls, got %d", len(store.updateCalls)) + } +} + +func TestApplyImportRows_FailOnUpdate(t *testing.T) { + store := &fakeStore{ + updateErrByID: map[uint]error{101: errors.New("boom")}, + } + runner := &fakeTransactionRunner{store: store} + + rows := []kandangHouseTypeImportRow{{RowNumber: 2, KandangID: 101, HouseType: "open_house"}} + + _, _, err := applyImportRows(context.Background(), runner, rows) + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), "update failed") { + t.Fatalf("expected update failed error, got %v", err) + } +} + +func TestCountChangedRows(t *testing.T) { + count := countChangedRows([]applyRowResult{{Changed: true}, {Changed: false}, {Changed: true}}) + if count != 2 { + t.Fatalf("expected 2 changed rows, got %d", count) + } +} + +type fakeTransactionRunner struct { + store *fakeStore + txCalls int +} + +func (f *fakeTransactionRunner) InTx(_ context.Context, fn func(store kandangHouseTypeStore) error) error { + f.txCalls++ + return fn(f.store) +} + +type updateCall struct { + kandangID uint + houseType string +} + +type fakeStore struct { + updateCalls []updateCall + changedByID map[uint]bool + updateErrByID map[uint]error + normalizeResult int64 + normalizeErr error +} + +func (f *fakeStore) UpdateKandangHouseType(_ context.Context, kandangID uint, houseType string) (bool, error) { + f.updateCalls = append(f.updateCalls, updateCall{kandangID: kandangID, houseType: houseType}) + if err, exists := f.updateErrByID[kandangID]; exists { + return false, err + } + if changed, exists := f.changedByID[kandangID]; exists { + return changed, nil + } + return true, nil +} + +func (f *fakeStore) NormalizeNullHouseType(_ context.Context) (int64, error) { + if f.normalizeErr != nil { + return 0, f.normalizeErr + } + return f.normalizeResult, nil +} + +func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string { + t.Helper() + + f := excelize.NewFile() + if sheetName == "" { + sheetName = "Sheet1" + } + defaultSheet := f.GetSheetName(0) + if defaultSheet != sheetName { + idx, err := f.NewSheet(sheetName) + if err != nil { + t.Fatalf("failed creating sheet: %v", err) + } + f.SetActiveSheet(idx) + _ = f.DeleteSheet(defaultSheet) + } + + for idx, header := range headers { + cell, err := excelize.CoordinatesToCellName(idx+1, 1) + if err != nil { + t.Fatalf("failed computing header cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, header); err != nil { + t.Fatalf("failed setting header cell: %v", err) + } + } + + for rowIdx, row := range rows { + for colIdx, value := range row { + cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) + if err != nil { + t.Fatalf("failed computing row cell: %v", err) + } + if err := f.SetCellValue(sheetName, cell, value); err != nil { + t.Fatalf("failed setting row cell: %v", err) + } + } + } + + path := filepath.Join(t.TempDir(), "kandang_house_type.xlsx") + if err := f.SaveAs(path); err != nil { + t.Fatalf("failed saving workbook: %v", err) + } + + return path +} + +func hasIssue(issues []validationIssue, row int, field string, contains string) bool { + for _, issue := range issues { + if issue.Row != row { + continue + } + if issue.Field != field { + continue + } + if strings.Contains(issue.Message, contains) { + return true + } + } + return false +} diff --git a/docs/farm_depreciation_manual_inputs_import.md b/docs/farm_depreciation_manual_inputs_import.md new file mode 100644 index 00000000..2198bfdd --- /dev/null +++ b/docs/farm_depreciation_manual_inputs_import.md @@ -0,0 +1,76 @@ +# Farm Depreciation Manual Inputs Import + +Command ini dipakai untuk bulk import data ke tabel `farm_depreciation_manual_inputs` dari file Excel (`.xlsx`). + +## Command + +```bash +go run ./cmd/import-farm-depreciation-manual-inputs --file [--sheet ] [--apply] +``` + +## Flags + +- `--file` (required): path file `.xlsx`. +- `--sheet` (optional): nama sheet. Jika tidak diisi, command pakai sheet pertama. +- `--apply` (optional): default `false` (dry-run). Jika `true`, command menulis ke database. + +## Mode + +- Dry-run (default): + - parsing dan validasi semua baris. + - validasi `project_flock_id` terhadap farm aktif kategori `LAYING`. + - menampilkan `PLAN` + daftar error. + - tidak menulis data. + +- Apply (`--apply`): + - semua validasi tetap dijalankan dulu. + - jika ada 1 error, proses dihentikan. + - jika valid, upsert dijalankan dalam 1 transaksi (fail-fast). + - setelah upsert, snapshot di `farm_depreciation_snapshots` dihapus mulai `cutover_date` untuk `project_flock_id` terkait. + +## Format Excel + +Template tersedia di: + +- `docs/templates/farm_depreciation_manual_inputs.xlsx` + +Header wajib ada di baris 1 (case-insensitive, trim-spaces): + +- `project_flock_id` (required, integer > 0) +- `total_cost` (required, numeric >= 0) +- `cutover_date` (required, format `YYYY-MM-DD`) +- `note` (optional, max 1000 karakter) + +Catatan: + +- Dalam 1 file tidak boleh ada duplikat `project_flock_id`. +- `project_flock_id` harus mengarah ke `project_flocks` yang `deleted_at IS NULL` dan `category = LAYING`. + +## Contoh + +Dry-run: + +```bash +env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \ +go run ./cmd/import-farm-depreciation-manual-inputs \ + --file docs/templates/farm_depreciation_manual_inputs.xlsx +``` + +Apply: + +```bash +env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \ +go run ./cmd/import-farm-depreciation-manual-inputs \ + --file /path/to/farm_depreciation_manual_inputs.xlsx \ + --sheet manual_inputs \ + --apply +``` + +## Error Umum + +- `required header is missing`: header wajib tidak ditemukan. +- `must be a positive integer`: `project_flock_id` bukan integer valid. +- `must be greater than or equal to 0`: `total_cost` negatif. +- `must follow format YYYY-MM-DD`: `cutover_date` tidak sesuai format. +- `duplicate value ...`: `project_flock_id` duplikat dalam file yang sama. +- `must reference an active LAYING project_flock`: farm tidak valid untuk import ini. diff --git a/docs/templates/farm_depreciation_manual_inputs.xlsx b/docs/templates/farm_depreciation_manual_inputs.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c26db926077a229f1fd1703e74bbe34ddf2aa53f GIT binary patch literal 8668 zcmeHNg;!Kt-yRyIhVE{Lz9=P~f*`}tT?2!3cejLq3J8cph;%63;7E6f(kX&;H{W>U zz4v=__ zNZ#4W1M1{qrtRwjbvNPjadcqJMMvd?15lCo|8M*kzk$-{{Td$xfQqLoSMsa8skCx{9rgvhD0{vtuuTayev=MXHF0XZ?lt2Vw7=9Bad3x|+1`;zE1c zbxdhQ_&;`Uv9(c9rMMXy?7~TDWCW?q%o1!e0qL%FAGCxOmSj~+sbOT~QXwm^rn_~C z7P=bniVX|JdS*HnHFW2RMEi=yfh(gV6OZk<{7ny6cpsq0G5gPwdzrq`WYULlS!y-7 zU8JeCx^ve5GHGswIxi5F)Iu;fw1C;vDnhR=O#QCt$x*RkSCT!bExjMpezCX^{`>@z^qKCYU)Ovq_C5Ft&L_ppDjlX32nZHa^yu|Q+pnEf=#T7#0= zk!SHucecVKF%j;tptn7}^`h;MwykIboC-#8wYewNa`>7kjLN}NI!0jy>K=W_1tkDr*IYM&nAyRd?NLiY>K^@%r`F>pgC&&L{ z4gO{6r77xKu%(ZdQ0=22b(UgN* zg0A{z=I$qK_As3+3zx| zE~zifQR`h`Q5xS@r~(f2=~3g5yrGREmwXv&*rjeTZ+TXZGAReyE{m$H6M=6&8_o=# zN-fyHmyQv8xHa~YtjFEb_Dgv{j|0>3IkgT1CT3S@ne8Ud>}zK2(0-_p*+z05z^jqf zqs1&td?qxk+|33*4mPY7-tT|qix9#2P|~y48y3e&{o!#s$ot^D}c&|S+gMxHnNGt!{qf|>>4Iuz*!M%wV@XquiBl_aO&%CAc zoe;aXnrnug>6f6R1sd)~6a7zW{AkWmzPp3nUZ-~nmeEL#J3xg`h|&E>9CjrzoCgmF z(XcJI%jmb^tv9eEdcYqTY5^u>221EM?_qkEJYg1J)M*K%Jr>Fh3f$X)hvwapyx^dIfo ztoC+KE+BXMpOmr7>lzeC(&Y>p?hqpHKvL$fWXabyan2MV4SZdF8*-%~nEwXlh4!Fo zjmmdzJEKcVu`V3lssx8;K%MK|rl=(0ezggvy{WtF+3veF&Q3;PU;6@lJt6re3?=$! z+GNJ%LPzVT;nGUkRSM+!S}9OiKVIXjfNQt+;0w>3i4rWpm~~-wtwwnJND7W6pY|ZD zeyS?2rA4t`XwhAkNG_`Pxp$c3S7d;E;HOEO?gA5TyA)g4KTIi}u$QzhN!$c}m96E_by6V|uQu2jW8CeFoF> zcOuG|Mst6_2jxWrK+Njba|9Fk^cjV~o@PzCCJiAFcbHfbjs+@BZCrExIZ z*0<{xe7`0BiZzy4y4UsoMXK?<`tGtfc3dK)SjDRU6)o1Q93mHKLCF-a$_PwbK7X5gND}tCGucy7c^w?bR zEe6^2l@lRRiS@Im#`KH(ChiEfGns-W+7Rt2|95JIjPIBxv-%sWT$&{y6wn^4P2&xN z(KSxWn}Yn@B;_ejnhFxyV~Hp=`vZ!9qnSa5Et&Q2Q8nprG;@bpxIwKU9&UC{w(dU; zru;v{sqi&2mHMi2EF#r0MKFcRR-ZUEah+%8=Z@^XGk;oC9c}Uvn~jE({a4?USI+%) zQuORz35n-eBDj*pMIy&CBc#ewd2$g<-%wTapmNgrd51>_%w7bs(j_Kxbuq^#!1@3X z$b|)>w*8`}iVSWxt-kmel;dK=RmB~ibZDR%I`(|EU^IPXWMK?;Jl3PA2dooLc%^Ff z(B9rlsN2CLBj9xrGl4)txbAlkVQgqBO?0>FJbb?liUGg>sruXm*H_f)zO{u%^Cgi7 z8rwN$O5}9t{8k6CZ246Y*9y_fxV@H|TI#{eh*5kyyI_}{j!qglbCx$8XXO`mL@ykx z5Kf!8agNhQohi@}NXv0);43{H>d1@Fa?Fzu{O+WjeO-CteE-osJa-ooLD!4^Q?$*& z5vqI)m^RzD4|oYLVajJ$7Np_e>z&4|8w>6|Y1Ipyf7_M3?*hTA7yy75)sI}~SMhtm zppH=fU$25cHf778--$+qbdKViG@i|Z%Ddyr_@2-*<=HL`RHFyUYI z%m$tixQw9(1m48=Bo~DmE-P|PiH0+M#NyYRY@-tUgru8mjG5dVCc17)Kkx5*HZo~X zNWA&VoDW+~B4}H6^3;X8GASS2-S!J@z`Henxm;m|=Wps)hAa0fwgaw4b*g!-vdjs$ zAXN$CK|Ih}lcE?vMsj_pP*!J?;0jL4UQeEC&nVZKIt-d;=%`DqptYa|Ms;#d=w@x7 z*K9cQCLg*arH627%gdZsxh3%8Cg5}2jRgKO`es%_wt7Wgnn{9g8Efp6lbG1lPn)d6#p!I$HRx+SGX>vnetdAMQpr<$RoZZL{5t&kOKZ3uJL@%BGXjhup~r+B;zE5DbKIrkBU+mk*T$k9AkNN7mozRq^1IH!F>0s82j<;WA&YH6qqqCYSOKzdRSoi1Az@L2>4uS>WbH zW+L`aUye>Ht z9yX;(2VI|@n)w7@Uwr8AURX8ZUx|zj-aky^54v5^*|9)CIkST=S3iz(47Xiw4!@?C zXx?=b7Fv=|F<+WPyqzafGY=uS@j)yMSW>cB8o((7B#cj?4k*q;K+mO%}yzy0Y@A+O~7+57E?q^(czz{5#c&&$S|?p?d0S2it?26B5vZIP67stmHPTvcLh1NAo7_jidx{Fa!5d?>o^Rs9?Y=1`-}) zL~`xE=(E=(R1Dpm;rK!74Du|3-gG$pR;VY}jRp)c`zg+We(CJdJOjfeH5uZWUzeGZ zcxFlxa_NSsg_K2%)~HHdDjwA&OM&>YIcG$eEIe$UefCI#@lrG4Rxsx%dvWnh6ObGA zT{G*%@sQ^L9xLjaudH@>m8^(to=XB~i+~?<`XE7Ul|ALELvL*SOazJyP%7i}wPjvz z)GjxA`8@XUEoxCJ;3ZaS_`(`56c3JBpR_a0GPWt7fvWQQag)Y<(Hw(0w!|5Ohz!Os zGcr_VA-mNpA|f;{BINMWm8VOygl5n19xE5(^XC#7XJe$SliV=(rMNQrs~~*aYrd^M zP6Ea@C_fn6pWKUk3@`cw94xCv3EP`bu@5h8c3x%N@JiYDiAS?5n1g|nhZtm++{;{A zC%;l)fz<9rdN6b^6^Z0Rf+FAu>avVG<|^pMw-X1jDD<6QaG5~go$V|#`pbQD3hgtzFm#Op!z?M|8}3%p`!P+5ov z@4A9MVcW9uH6Db|xlI;hSXNa`L{c{u*>uG+)b0s5Pd)^@a?5dhvueGUd*?DTo9@Zi+;k4w?p5m00%27MjMh+75NyTxxik(u&uP232WroHwlRG(h~uAB1`h=$Gq6<&^+g3RF#8 zYkK#l-ZH0KSkvi{kr`Iu?Y{gda(?Mu`ELB45s_-dUeq#%4{_15 zXDJFV`vRPyR04euP6vm-?-WN`kxiPW>kJ_N2#&U0`xkn!yf6u&)irn*bBsi?|pn} zb}%LBlm+R-0uS1xEeg-^m% zDzDXuR}0a_+AhzXksSSJUK6S@aSs!j)4W7dnDRFk|Hy0HVNj@tJO8iKuklTm-b~`M zC@_%0?v|;~pQF}Yxg7?m@uJtI-ah_>U|bYEdUWS<^-hb{)`Ij?&GCEZ#9}$$IpNi(dm!HF)r_)lPGUd+f`Ud@rbR zuwz$mYkb15Z1HvbOw-HJ1Mk!)JZ1HZ{u_XI_M&ax(ykFWefnAD@PRxgqB^j^Y&vo7;ZcdRHS6#?#cdMU%Fdg!yvex&7>NvDjwk>|a z^sjBo`i^=bVx>2F?)0GXI&O{U1zx=QjMj0(+LfZ@8JPk$V`Pww?3lIP0A`~c%)#Y^C-ck}4Pw$l!s>ZOXIo?v#|`T?H(ku?b~aVa z>NYl;mDW+4qL?FsgcNgolU>?}Vy*$nNj`!aS=f!7a%HmHi_|=K2`%_HC-XuNBf>Ekf3aVC>sO@wM`Gr_obJiZ0oqp_#VNnP}C!rM!auZ24w|frLi=O#9 zid#U<0r3ZyzO3b=$yM59i$NqgeU=)X+eV90iuLlWFH5B#T}8=_#o4Gd`?M^IBlp|~ z5w4M4I0g4auAsn#&Z(rhTYK=+#&_l1H+{G>+PD;FC!jQL;6tLk8&^!ALJige;?3 zJ6mbGIlH*?TRFQye{TT&uX=_&R0C4X)IJE1eq6w4lkfG3y;9YD`d!u0qYBH-L5oft#nHPp%$)|nECt0?t zl}aGa5h7Z{7j+_HBG2t{`E_Y3TQMmxQrwZ6A7&fo8zo`xpu{ZBgu#&fb>^O=j0+yVisrmU3h-+E*!)e?^azOLZRz!B zgvhI9jW1z@%ipBx{e){BGph|4SXMdCtPZcHrSF#O)PV>J2Qc2wq8?7Cie;O2E>nNK zV&C#H3ilDkyjE4;liC5G5Jdt!V*GXI-*>-#oY3bK?b;aMo2v?$em(58su)ZG?T>i&8B;9@= zSt(}77!=7mtZM?L-GnTs=S0>oM$3;ZprQ41Rt?W8tiT3BjUwz{-oADiM|`=MsJmQu zIT}x%7;N=4c$)067pc$ZmYgG!^Df9J=Iz1FK}k55+*a;_w0Ya0S~ARbb>K)LWSJ*-G*H z(+u4XynrKP`bP9p9V$v=JEMt)l($NO-^Fe5T^d`0s@XB`+_imbUb-%$eiGT)1inrw2yqPnomWO&%62e_*n)zRfqUP&lE%##)p+ z0`mH3EQUKg?wa;QfN$^Yzo1rNXM1K&ow#N!QL#)mPBvD@CFzpy>uLF=4NaJ3%8@TqMKqK6buK-^wH;XQnXQmxJIn5SsR zNn>$N0u7Gs&Q*TnS8bHpH@+9Ae!eX|((4<%w!g4Fk~Y3Y){lM*zfe$l0smhg{g1u- zWB!+}x~AIS4g9_1_XqIDoQ4$0U;2MP1OMIy`75v)nbQ3KJ&~X7{9GFU)6y>TuL3_6 z%6|s`T=4u8tc&|A_#frZpDp}cocq(l7T&*>>V7uxb0YVr0Tg7h5BdD=|4Hk9hW;Eq z{|W6T`_Iz<5ljDUBJSHaZg-lFrOiSkLC|X(C8e7^LXgOUmw$a<>c-dl8_`>;H zo-@q{wT=xYWF{1 z!?}OQdIN)pCc#VrS5xyecO7g!pz?0R#kK5m*V;lF3Qw&f6r?YIbN01VQRBzIkDY2+ zwfA#$)ylR?w(3h}TV3>r+r*A6ywB&-#BQ%2p}~9R#Mbkg>DDBF)f}5GX%gSgU6|jp z{kC^DOLIBlugo0DjcU6#Bt+jEd2_bF+gt?is_W{E-}31Ghz2QR&)SOyVmhJwuk+sc zTF0beem{vlW~XI^TfY3-mq$wmayK=T1zOp-@(G+q8KGX@M#t9iT^9X2qA>KJx|F2w z4fm*`;iYY*lB*@CSvJ#UFV2-8_5$F6xwV>G|?Q=VssF z(@xw@>R(JuG};0tjag}#d~*Bd5fE!fLDn&Yv^20bwy+W32LJuP>G(f52WOc6&PPo> zZ~OAw{mPS`MZ|B--YXSU9S$gG@@lzXJJQ6O{O~0|XER=iYo*qPn+ue1+3u5{8=Ln% z>?-0N`@ZYl{ncw^Hm5km-1aA3wOcGKX#Mo?)pxD=tL(pxej5u^QgPa1pY0|5HtqEb z)ndZt!&QR^->lRRq+fk!exl5AjF zGSAUM9Lp02D{J%fR#xV)dNFcl9w=cz%Kz+t-f60-=5Jq_%}n*&?htIhc2&KtIA71n zZq_BmNn0BQc_rLO2%N$hdS_}>#TQuJcIq!Hv?uOn`M!W{^rOh@d#e{Zvsv`ZEV3#a zE?cnVd~eLg8%m4VR`+yuzwKpa39?~Jm-8tS4PCSM=lB+Z^`0*y`3{bxf4#lyQu%hf zotJFBaDN6dQQUXP*}9%Sp57w32F*jqd3BPm9N{xkDFe{ag+MUeUCT^dyHFq)AlG7 zuX#N0@J9cm#tN32BOSz2ug9jMe(S}av~QKZa??`C0RMofVs%>OvMiIg;Gy*7ldBir z{*m>{!NW(!cU2?~5>qr-mS&eb(fw3MrP`AMsnC+jm8MBV!);GeRf zGV3|}uV7uRNF|Yafhz*v8Z(9hg+;ENxvNm8$b2u$M_~A&R#5CR?`CiFxXOWp1FM&x zsB@WL75G+jd(4Bgjq~;|=I)fBEL?ocb;$vnjWvp22u+_)v_gMgz_7yqZL9PIBfXdEQf0vR2FY*g#bX*^6@yO}@L#dC6hszlv>DgMGIzKSDf2NEgKY z5uOxBR}C%$Po9H#TZ6@#%|%0NW1|zc)@GKbHV~a|%>eN&4o~eBFDhJic%ARGx^16! zj9H@Kv1qn@s%-t<{W^ABy@maSAM5){HVocfV6K#Y^z_{|%6@Nm?@o7>$=YWTagJ5% z=G9y2`!<*8UB&tDa9Nd{pKg)eALjS{%^~J2YgtYHe#`j3+xG6dN1pfMCwryN%bFuY zuS75Ugj-*C-6pwXk2iJ68LH^l7}1?IhkoAgTYaqTcx~X&p~!8GJ9y*w8pkj{P{Q?@ z@~eIAylSkwFGnTv7Zdv(!!PG$4@Jm*(&t`&-eml~MIm z#m+* z(fG2lIQsVv=(-;55=&0WHO(BqTeloLkd9~51WQEDnUHect+V}ad@W!t|mU(9c; zl*y-UcjEc}v$iLmXKuR}K5xVG1Co?=3zjyFY<4bwY@&Sk9$VqouYBz1F7EBn-`L{f zH2&qAIqT}KkaOFXsLHtas(d3}*%0sj>Ubeug3C4Ghl@h^u7gh^(%LHG2jY5NCo4}T ziCuVhjhbxORu2pLKeTA1k2tWGlh^_pY}F4uOz%6w^zRYm85S~9h~WbTk+g_ zZLQU4FvX-*eSyZx56U*%A}b2?UE*p3F09a?erwIF?Ydken$?Q4@ffyLbQ8_`>j$+P zK<;zq<4FS>p0sg33P{?MVaFVIKq@EwhSf<2m(iylwa){5Y!hz8?gsd~l4uNHFUcBD+?NdO6wO)+CFS)h_Oj|Y`@_7~d%-He4{MX;Ng&XDHb=V)i zZkf2^R<Uvb&-M}Eomqyia(Wg?d};fP1k*^uJ{x`qOErvT~Rb_T2a1H zPS$0-vHqo!DJw3J7^A0cPu}wIXr{lsJB3IzaKuqac_oE}c0KVnkGr_RVSjOVTHDD! z!+hf{p*V8;rz)WeJh`hPY@Z^-NN)N5ny!`3sKx}H^Yx8PZ*YleV&u;44B%fcs ztj%th-&Cg@to(?-IKcaL9H@rg~mFE2G&l| z_m6db?v`}SJ70QmtnL-(OMV+`K{Jc<@@v_)9B*$n`MOBnXl(iM3h%{FV%3OxH)3qV z#1}g3*Hu$7zW!SBL|@SlyV9PCy)Q4^O8$IpOvfkGdqmgGKy$1B|FXyOVa`G|2N`$H z-Gm?cQc+|6yIi^Zl? zTl+`*w{4PL6S7QP(JMc__lB@R zI8to&;q-d`2Rk1ezti!}O#hj#No=FBikP$Tn)~${UoT$HzOO5?O8e_yK|v}9PV^_W zNlJ2Fkz{}VuFb2piPgY&`6Z>-E5yTB>4q-y>0qPsefGJ^>!O_R^db3I`z7y{chi-Q zd>#BYaR1Wt=$e&fF)8zI47B)|-+Y&4)xN3I-e=J9!2+|$=8MN4m-8HGwTZcs^X(_c zViDCn9=1G%twcx{p&g(0d77^h`}He|@2`R?*V2_=zO8)3m;51T^o*)KUX- zG7q(!JGm{K{jF}s==%dt&erN*+3u?%HA_tSw`C07Y_|UPX3_cgan&Aixbf|{;Bzm@n>Na(Eeh4#YAf98zxdI) zDu2Rej)WI#4%9yug`iXkKe+`=OsAJJF|D8%R9hztV^m3Bozel+Z4IPu*Cvmp^BWym zs8zBk`s*IGOG3Tht{r)|fpdM-=HM}cgTp9EM}^NE)9toB}AQmANb8vZUm ze5pA70TBprTN9L=+a7whjUh&btw-Wv?FP7vEO*x@Hzkb+a(kGwLH&F6n$b1{dL;A zwNHzyMm|QUbpLl=Ar^^`x02SpV&OC8=kk9g`@Y~oq)*p{MccN@q?|Wk=DM7)o6B?S zl5)ei`8&?+zIMFenEugShAZ=VU+JtWW~)2XOlmzdPmjf~-g-v|)y7$9uyXij$=XXF zFUD&3)CekD`ybwNWKVam=#IzMhf8e_E@vKQGS%d@xfmz6wISrx8qWu7mhlO8TE>N` zXbprdi+H}UZF`iX&|VYiqU?e*Yz?cWw<}y?<*pWrZ%YY^t8I+FdYrSiBtogio^MmH@gYM$Tc89wpZ54Ij z;;Y&GW7Q2+?Nn|2EmGIh%d$zFdO{bH*j*OBRqb~^^LFd8aoX3hYX(ZowuM|@Q{-}) z`Se&d%@n#m=+s&VMsW?pNmYg8iaaXXDmzU));@h!|s2#6vm^k9L_Jz2!$@^7C z&d-k?r~2<-VA8TY+Qv>+SQd9wQ~lJYn^Ic`u19-Tl`L7&UBMsaxjO6DF}DbHh4pv9 z4RuoFfJwt&X4O#}HgJa49Qe9~(hw^rx~DNewWIHnrMeJ*JjcgN7Zz8IUP?T|9@`&_ z^NReN)8zU3292XFw%QT_PZDfWHsj=r_;xiUji?pQ@7+Q4khhWlE7V+wtu`@AVqa=U z;*oPg*E_6Cs=n<}zOjn0XN)lE4z6m6~5>?gy*1Ttu;8s+`T%mpS!OxAQT13%9S0r*u z$~W2E9i_-qdWWMV@dO-YY_PQ?GkB=5F<6plAh&OFWUwTR&^$?`jfvyVShl#jxYB+^ z5yLj-G~qKR$duGz9H~{F>e){t4UCEt+|%(m*9%JHL%q#q;$B{Ocj8!YDl5JT=T7VO zV#T-M-pfx+w03t8y~b$nl;K7zyn8l|R<)yZI4ZTdw|8W0nCxX>*|jQ%O!DGtYwZY= zcTb~L<#`h12so#Aizl3Eos^d$v{VB*DyetkVwn8oW>4}YwcEg*Jj%+mQI2-Wb9}HZ z%F7EU&*D7%bJ(+=I$B|{F(-#M=o>?}GIpf3mJxCqX!c_`icRUq$0HMMo^h;%jqdJ~ zFS#c!%bIS(xn<_K(OQ2f>(bhR3OeI6Tdk);id7{&QI{g^kpfmuE0`op$M04GXE8EKandQv2}@ zWY-5yuH&QlCfa0LGmd1mD&;4UXQMpToo2PCr@73F)ys=Dj3_Ukv%u@NJC!tm@5gg> zTshbqmC94a8oM}pF-1Lud$f@zT@z1pkfEfCci^b~73LHEZJfha2879RY9nQApcCgl z**VA|qfIuU+R}!}1UXr1ttXz^LH5E?jSX^*;(Dl+lYLQmS|_z}G5J`X0?jes)p6XY zFbS74F?p3{*{DRK5V}N$ZHroemU$9#>NkW*j3v4Rp6hTs8c&-v5Bbt4HHj0f%X9@l zU4t~<$?k2vESuPNa=eOMNCa*hZH=?)X|swNFVj~O@f`}`Za?#PfasTFwqZjR}&DN8* z)H;9NG4IW{$Cfs{wVwcw$V>a;^p@nfP!nr?!*Zjq9<;tRW|--EiTteN1oKCS3f!3U zC7ZpwA1NOzle^%remQK|^%C!R-$aGJ`sJlj1*?jq>xtx{=w6#pb+Ls6Sz3K}bWCgY zUOuU0?Y+rv<-^f_1G}?asCl(EBCVNOSCy@;$tgd&B<;e6t*zr$Tp1~C9_9%>wykD~ zC%;hYGNIH8C#m%DSUFm2XM6616_?MmChECQ`3Y1fZ*M+^dJ?2?H4`?MM98&ed zW`Byr;9Zpt+xLcjjLTnK-n~a%!@o*#OD{H(Ve?NjNHM?*AMD`rl)@Qc!$+pZ$f7Y} zTlCE3ftxox_X0EE8eUUtykxBL+oa!zUA=qrt5hLBk!?LDru-U1jKp(>nC0}fZ5vra zB+iA|2+AGrd^Rs+K|;t~buDSr{T=K2mV{b((+GsKD*%4&4@gaDW&f* zlun%5rFT=d{YS(6&-7TW+d|sxuA|S|?ylFTp?vaQR8zRqoulveJzN&8GVw$wiS=^h2Dhw7bl- zlus^;ZVG>VM|5C* zIjQ`P5}a7aA%D4uf{cXfq+pid31{KV&O`Uz*J@O3$;#D~%nm4vN(K*;Bb#mS6xP;$ zwshVRpl+^~*?Hw*TvS5vh7C4-JIwip>owhn?p^O$do1&aZy@I$3GWCn!LhK`z|fwJ z-&7Tj_k!a$N$Xr{Q1isOO8J@CylCk0rsZs885-5<3*NNPN?cj<7X7! z*hjkjkk7MjQLFY7Huf)bMMQgpFG_Q4a`C9Llz!IU9k)I6ZidYfw+?H^;@T7GR^das zUd@v(?4H4{vVLU2OBDj6p(U+CP~LI3*h0&=lMRxU?%S%vxQD_Fk6ha}5Ym5VkEg;L zTQw!-42_%iTVAQu+U|P6eDWf=nPhc?Y!SDes20tt$6_@xWEybTR@NJTNE) z)wb*WGv!+Jaj;*XqA-Um&H@NEUQpm=xR&*+JV zo+mEj%r^$n1=o1Ip$o3@$iS{P)OvizuFlqaG@!-en9I!hT=*5h25)vt558!?(N)Xh zk*v~ua6zWVN9f#kzzH9v6~{98YS43M5aa=b|sUk zQDI*crEUs-az!LI(kL|EHJzj=j2xCtA0mE%$q%Y@dE_HDb;@Am9aHIw8nAN^q&UZ(fj z26NxTK81)ztYl8S{U*Ev4R_MRJiCwMIe|-zPTC4xsL#AD`w{rLfhra2A>GF+>t?}me09uAzG9Kyg zeJs0ur;T`KUUN>v9lKL!%(X&AcMs>n-=WLIvo-=S2z06E?i(A`O7kDx&%2u}tLKo` zSbL&%W%UA&>Qd%@76-DkFxL&|=iWi-{>`nH2I?M)8mwzHC5Q_qEUTeh)>LwAD7Rab<`%)xcX-0mCu;y{a&}LzU@>$5rt5@N+7K-=-6p_@ zmU-}p4@b*^`KigdyjQkl#eL3+ZmWv9YY}56c|_k)NgTZStYSz30L1Uq${l8_W)zJc z7e?u^(IxB-b!>KQx1*MtgJm|TXGg;2tsb>|ICy{PztdA$YbI&NinrWH$rv2(o}8!! z8l!_%yR*ugbD1sCFnJI<-_i zg#Jv4L_Xk`2a(X6lXFVHs*<^1Q#{KzYd~5gE3Qd0Lf=uysnzb3vIn94um|yEI-z>- zZvDZroJcbC+IY1EpQ;*B&9mH%Qk8Em?cAzI>gnnGk`Cebmq+b|RIYK%>t^3KCpB-O znMvFBm6!wnRx&%F6cx{U$pPz9A5`gXkHPoH`c-h}PX`cz0EcLf7U1lVwQ_1bkt691 z%`a>W3`F`RE1Ba8EpawaTw1xaukZoTvIKIw&5gl>{;#GM4nQASzvhD=mNF-n0k6T` zq8jh2@9&f@W{yjoG!4*C+`B3Kaf3mE6z3aupOvWAdJBBd^^ zBe|I)6OB*-0P*zoUs;A@mVoG(o)Qdv45q+m5fyEH{Nh>VLt-D5tTT^O*MjYY9;cKf ze06CBT28D5+YEj24nV3iv<8%eYBh#Ro%(yT!Gtt`xEYZzfAboEb!c1D3-3337eY^v zp~fIEUUD2WqTR@}CBUcz_hC&0wmPt3(x`5-z+KT<1mGdnu(oDyY2v6B+`ZGWbUhSVz|0B}NlE_?L&w8acJ8O9&Q7Gk}$JPoE5!nImw^!wfOwP+euX z`70EQ)EDlBn~i)w}C05RE_!o#Pp~he=DO}*5^UQemS~;XJj=rP0kO7u6!QI_$p?h2Cik8K@(XpauTe% zI)P8lB47#yffiwckLr%-0BW>WE%f~Ga9LdZ?_&^n$zox0WGL4Kg#ObYd*5~sB1p+S zo|92iH}x?6CahS(0E(e%E1ngF5;(Tg8K%s#D2RlZFd_s{pd@5_APb^EP-5t)J_Vl0 zaoFRIVYHZ}(v${Oz|lEAuQ*zUoj`tq(w^S|RRX}JDLDuh@y^agapvWD69ezB!*i4u z-Hl=cI0vQxGy`q{hTCxI2Cg9j7YHQav>CyJ)-XqrP|*Q`9YpwdBpHB2r#o91T_LT` zphp#OE*RnGf@~%)$IK6#G8G756auteL;Y`oUtQtW%y4_Z2ah!I$6;V<6~F?>g+ZQS zTUxE=s_&={a~uRDSQ-w1+=k(IclBE6;)*M%Tmerp_71F3`O_Qc0pQ>_&%=~th;Z~k z2|PzGvYxSQ_CP4YAee?=YeArWUpWHdI@otcn4JC7N}$YQdptt41<`=%*J3q&+a1Ok z<_{u7&Y=n}Y>`|^rRnuxB7?XDED77K%U~;hr2GFCwBUGeKwVyi?TWsk8ALb~pMc|H z5BUw6?J#E%b35nEp^yAee5u>D31G%k8+UX( z_^P%5#ZG;WnRh5eg5>W`tq^3ss^!&>QwM%_X9mmg>6qKANh-s-ShGyL>ESZ-lNn~u zLCBex#diJ$3taBmR*%f%hA}FVD@@zS6K)3LOv~NLMH)t|zjZnFpXw`LgJ#%b{pjRQDBzkY#W+7-B#i2x)LK69+;7 z89}iE)Pd<9^hkf4$*A8PX4l-sAoySkO3>|1T9~~Y?GXOEXv~4-iMs#;}q;DwQAixr2q2<7RojHIe#Nl%X ze|~M=4=`t->~Wt*6*JE@jP=rY1F~MA03BVhnSas`oXUSS`QxSN#t`9=m|?6ND6ZEL zsLZ$thy>jO_^6j0a!mE6dm(;9qb{>dB&#sOfv&);Xn^RBncN484a1y2-P{d14K+{^ z+wJij2&Qo3z198bc7YJ!nh)XvHWolN!@_NP0ec3@pp9!e4FLrTm=!sHyvf-2p=d0c z80DKD0gz?V_Z<>H%s&|%pp%P92Xqf?0agkakEa6|Vk9=3v5a-}OPG3naIXNz&MXoA}*z-$Gr-Ue`+9t5+GTnf|&=@SBTiggk4`@zV-xJA`P-~qxht$ z%dSB;gAl~DqeLDD=d^e6ryK$BIU4x6(ba3V+5iB#e3>}{vt(g%dXOAq%=(&(6UgbQ|o2` z)=5(BFf^1JG{x4nr~1$kix@1a<96$RXa+k9TRJd~ zlu;ZtgK9=s1A>myCU}+t={>_Qn2834AP?y@3B>LnT}1Z)Lp1;~3_u$y$d zK??-=EW8YLpQ1SA=3{5|xn})wd1MRnLcVrb`*51zRl+1Sd2RJ6QhF zF0?L!)w!n~L@d)QhR68PEW-jY*I-Y>di* z1tQEJddvYBsg+kdZju24S7r^JreG7{VF*(kK_<9~$Pbu^-WE(hhD-vOLno@3R|N2& ze9*U_8Gt9I6@-+zBjLevw$k3JA+T0kpIS?2B$0@bP$IQwqlW|oJ!X~s=3Vfpn`}b< z^Kft|0=>TJoX#+V(&3@V1aLdkiRF=qL{QpE+nmbUl6jfn|B%G?IH3aE_x0 zcl4^tPzNSCclP~etDsqmks|{cs4}g=kU8L!;Qo|LO;PqhmsTK`!dBP#a-+B_2q~Zh zjEX?x=|}5UyuC2RIJ`B3)UC;s0Ph*204Rp1m|I+OTPgHreV? z5+qhw9{}>ZxdzR63=@ZLdiE7Cl9A89tpNBY78%n!hM;3^WH4@mjl#!a!44@fR1o(S zKpQOa@SR*X2u?^Ptd{`a@27X}W~@MGys)z#CfEY)2|Mm+fG`o8h=3AELzoD(K$r+2 zbM|98pO|WjfB=Qy)uf8K1+XCqxQKWN5Pe`MGfp^|9C*Tk{?qN^gXfCGH}p;kGfb1R zF}3@%nFKq7Nj#p-Bot6a_Xe}iKIDjj972a2DE!o!sY8wjme7$pbBG5JLnj_^M1%=3 zmjekDS&nsL#?+u*utSqNr#12~?&Hm-6-LAZjGrPN;!_xGFkP8E(Dp(1Fnedmp^Sh= zV&M&D(Afx>0eS)mW01;-jUeZt7D^6iq0sxF*hptJ@XBZQ^MvY=An+cCV+wiZN{7S& z3Zkdz3yA776oOR=tPMH_{UEilW&XV>bV$V1fq~C49hPtL+`7m%@Y)W%@PP*$u$$aN zV!TLyq)O zCmpu98blE2%A*8BOVK$Aw(~Im=(a&70yKc+3^I{LMJ=_6Yy87T%u)sR(XhQkJPU;# zFRctjBNi_3Y=ikL!wOO*d4Sa@9F^?<5@RhQ=D!t;1$S6uT&t_#JpiG-btVB1N$*A5Xit2 zA6&*i1H7A>acEJ2d6MW>1^E5Ur1zs&@gz8U>THl<7J+!gm>THa&pfo8LQrEt`1A1C z&Qr5#M-DhO!;TN29Z+C2=&Ydi9X+saE&aZES)U{zDX{rFMLVFrH9>k+f4<*r1$*!G zLk>C~f$Sz83;B7;RN#B5NyW% zCsXKXgfv5Fgti?uIA-WD-Ce<~aeyZ)irO#6;7|^@asQsI|F`WyEMzsd(-TiY4<6Ww z_$O@R0)N6o4a5p85)f8^_lbZ6pe`d86lm!6o~kD>m9f42eK%&hV=6p?6{!-Uj4hfD zBm^tizM(%VFd@X94HN@SDz_OCD76r%h5o!1C>FnO4}3?h&K9Z|5Bd^Ckk>G>31P*I zAfBeyb8jL5!F&J#1Z!~g31GVLo*aTHfM)x+9D(uN=dCK1>a&iztIX--iWDbyU%wj( zn?uJA@wB+QIoQ;mbFogrCCF>4beWyil~{9Cep{^eR$-a*)!T@>6+exL3W^vTD?Kg9be8+pvQ?T_9!x3sNu z(sd0>W_hV?dHF-%(;N*2>Hma6Ad0$O^ z;1b}^-g65lnRbPtAJ4%LK~rLiMOuwv(w8unwZ4gDojtnU+k<|od)_OakUQ#ADYnQV zeS?LSPtQizbiZ38QD!e>4Wo620zIt!_#+xuan|~Pt#B9HdbiU)zwUGjabY76GB~CL zbUTKV)*8PR;hwRg7T80odU4)5ht-$gPSs3T^+s=;{g{g)c{9-I4NxOis(<^DQ>jl1 zlKJa$erDzI8#X_xOM42W4YT;ZvXeRgGVjA!JGg%}yXmX0)pk`mFJ^AR;%@u=!=BfB zi=8}=%+KV0Uug)GX)*N4w^XudVqE|xn;$u1l)8COAAQiy8H2t|4FW6JPp|MEto&|y} zQd=A39;AR@=b0+*8+$9MVU9TEPUjb#HuyU zoOaWVtZ6s#TPa!CLOEdgXE?k-={5Q4Ao*Q$Rg{?3)`u=6Q+6K#!Us=c{KRA#~r4n+q$sNVBdg|9!5t#h^Ouv z;4ICl=(xH=yV0{U7wozmOf%q$yH>?huHYp2aFXLCP*xVDwllKwRL)dZ?iv1-z zJ-9syM+fN~Py{pGp+i9~bm~~Y%quSy3Z{YoBTVBre1=xnuKrWrm)&)bK9w%rB_q=s zP|9KhHlo@v^7SsCdP7U}!2NxbvWaIp(6DPe*-m3~AotILPe##sJ%X0g*Y-!JuWv*@ zX2?kyHRDgrYf1|cwF_8#3JvivDyvGhK5v~@! zopyHs>b-02!ieoJCW?pj^Fy&oZ1eAdMe{6-z*(TqZ+MjPhChqR(5){7s!Fp zwRx$84#ig-(Dk>Y46N1hTzuKyO?7Y;=W(O51~`aAPOwGJ(8<#*)ZNUHWJETV$&NDE zrH(&#pYWzYPSj05E96NEt2q=usN>gy+3;z%x);^Us>D~Qhk+79 zaw(=ueVGT0zZmKs!tTq-4Sa+q1T>}bOke+spH@|lVZx5|tMb#=?^#vD6s;WCl3Yk| zTZAX`i)1N$vJYC0rCn9Ww{~V;eL+`nXE#F{D0Y3+&}Y)I^d)C3bJ+7osC(r;inN%Z zr1bPEl?=z;FMh~ULU+tjOWQMa_bKdbawDIbp6V_hIBn3=(y0kOf42GGQJ_V1^210%(vQpAxG-meDulBR)??iaa1HuR`Uiz0v4rEV4vHW zys9`)8tz$$S|V4W-9g&2`R0w)-TkLl>aHBq)R)RS`GJ26-@?I4ri=Bh12_j$j;+}) z5;i-c7a4Cn#p~wsQ^5SM#R6`E>4!+0w4deOilVj4QlfNTvHBeHT&j+n95VU&4&LS8 zN696*$aHUS?UY}tj*kz0M#*_KFcNkv%HWK8dDF6~+ip+Sgi?J6Mws6gpZMA7b$N7G zeot?NXZ96C7wh)4W4TqPw~fdh0#QA3l=m?er36nuJGY-jS3_<^RVb)jwa1tU7N&3)5IX(sdd*Bjsr>ugOlqs#(_@89xdr!8z%-B1_M$4FJzTWH z=Y)M2wXR0rv=vMM+Rz{1ezqPc0-PIy@rO+9{!!fL4bdE6&N=34J0b!B1u>LAI908ufRo1C>&e!;lwy zJEX9Ad8O1uqo1AX-{ss)l*b6u$`YUjcgZ7xy!8%erUoFL)8^PUcPQ8edjW<`b^!w; zB^fmgf0&H@TOu%Z{_UZs6Nn>GrBytQXFY++^yQ%Ph9+~B`wt~6WBP(^qWfWvacS!K zcji-i{-bsEZfL8gW!yGGip>ZSkOTi}u|IiomS#|DsxEE3Kb;xN3W{C1yMZPnQUiHF zcHLbUibd+bjujaBj6j8({*UH?H639Vz(fQ~&dCidJ%<5KVc5il#eFNciHt=#jQc%H z6H{x~-l?eyvwBQzC63kA_)fIkONf>01yNd<5?X3`NW0c|v%Cd62zDh;+>m0X#J%S}iU}8%V37J2-Ri-z zl!Vx_XHKLiwyLh*0Dy;7LM+2sHax=Gz! z6NYeRs~JGXXU!nJMgu4MLK0#txI*$wKvw3JqFiJLAfuQ-5tTAU$Y(~B$KAsApBbhS zMG7P_SW_a?Cp$_W;9*@&XbRlxv6gaxHy#iLK_=5Li>X{s!BS`@05oe#8?9hvC{+|z zfNp??15gx4jql3}A51p{0@?tIy?Y>9z=h~&QCy5amJN|DyBs7tkXXMvkvP(U63HdU zb_nk>+F53fp~%D!*$Ibbp?h2aHb;eoz|CYvg$7lWqs!GBaZgM4QLuFoR?#&C@FzMI z+fXAc2`U(ggd$+~U1aYGKWr=LX*=6HiV5-%p8iP~J){0RdGUNv!Bt?tP*!};L<^QiR%D!aOi}vU>JuV4` z_5G^L)N$;nW+PBw%d0{t`%l4vm5?!MkQs3E|Fs+8iVloKAQ++ojjaa>64(Gx%at%; zjv<#l#b9ClCTKV|Mhcrq38nf#Dxhuxko~Q?OECl}p+ra|03|VipsC(yUx&to1w#C& z;7VV6AH=nxNu#0aOkk=uypK>bB}~Ad&Zi!kYuB1SlD0@6rG}PHVoT|kP)`~q>kb{TbU;bB=$1(2IRyv}KteD_qST^d_JZwc+b|>~GVW6o z4?{X5?I{N9UO>J3Q(a8ZeU@Rmp{jxow4izy5>j0jkWBpya<3i8%0q~~L0W;VhPdNI zf`(#2FAkS7!m(rm#H8s7pz$7~4rc&g3Z*Il#IgYyh7E$H4!;CJ9i%Q$T#pK%dk}W8 z`@LcQNHKFr&VWmwAynaG5U@}xb~&aEY8g-gu&#P3K){%HWaUjl^yHWVHB46oyU<4% zctKMj1rkRTNN^SOd`lyJag(u*KiL=tSrjJXPnMvx<(mSw*?vLv2kquG{XvFe6?FDs z3^_1A{FhSyI1o}Fe~kJ7;*W6=C?A8+|E^SwiOkmX|2Cr#5xN7Q25h$>B|9X2Z7vKw zNZuy_42b6sBu%+{t{2s1u$ZD3@Y#`s)zRNW&N7`k2!kKSD*Z49;vYLV?4x)9-qjnw z8xooZ{?Zn?wWmz=5eq5q0f#JEDQn$Yo&Nsf9>BQ350O)lK2)V}r{ z$-NF+DKxJJMi;3RDh3W;tU8E~0Kzj%gOEI=aOoHsGf_^$1stS;K^Z)KHB2ti+@!xV z4kce8{e&e0`Jw>5`og7@u{4Czu@ekbogbk{9D*7^ZIx*Qro|@_m4OrkzijFxGFw8A z3OLar{kpeX12QN}L4FSf=PMFL!W+95sg}ptl1MA!9ukSo$$PA~>0A5)Piv z=Guek;6E)v2Q)Yb0vhI15Oe_0r0JT`V-{O$dV&8+@flLF;GVtwOn1VgYYc?O*^%`p zcl-;fv$mO`=l{J%>BQnDiI%ruP~zjMC;TP=`2*o$a7Go>QnX)Dz4SQ5cp<4YP=|LP z#cv%}6krBpm6Z|he+Y1R(xeZp1998JSc4Y*86LX8Bhc8uFo|gsw!VTyJ-%nDd zE+ph&S#8?@;z%kgy*ty%wd#KKU(N}8J)IbqL*keWgIlc$F-O=-0jf+fCwXv+!h!S! z$I}^VnuZB@MB%YU9z1H{SLJo1o)WkR4~&pS5fF<4IEPfH_mm)SQ$xIsCK`x&8;dDy zP2_h>1QFurY-!7jsbs)}pkR7#LRVo73JQg}X@x+18gL|0@j!HI_FxQ0;A0FL{!!|m z1|q;gc0s~U2OKCLD`o#pssFz<3K9=O<|-Kel-!02&>ex>ZC?h?p(vPV;SJbHQ07Ww zK+oVXILb^1F?gR65AQpXGU=G)LO(yD-g5h?9+)YCU^tAr(ZG6E5pXbi z9EFF6d=qf2BEsxzD21o&hEfpERm~FwxgDsV8;Z~gZr4KZ13;vN5&XcLVUpp;CC(nJ80EVQE7lL$P4f&24Dv*>U#(x3UadmT*q7u zoIhonLvSH^px+Ff{w`0y#h2FnR^3W}GnhB&hRL9q&0HLU8)KE8h)jY(M-LZ>7`L1#J~Qz(Va zgG>io0v=wmXhaD0uLmOFL3=d=c;GY)c>bGG>A*94`TrS4^*rt7anJ?D3~Lrj#e#5x zqzF5M_2&;rqxR%~ws96RJw?-y^DXR68vq(hA3!vQ`&FN!;}Z)wb)W``5_)%JYLXfb z&rjU;SVN`Jun@4HI2+=YoNUdym@V&M6Q?kSNQ^3TKLdNssoz(EU5CA9rP+|gg&+wb zxyP*aaGu7nFL@J@pv|)Zh=mS7_C^>HV*n5@9f0J2BRf3b<>0HU5$&Osp!G~| zuNA7&sHfxn>93>)zv5{+3BLdy@gO{M2!O1GhWrZn8lXgvQ5y_F7B&Py7PeS1f?QZ> zcwnWWXibnuyy!hHcos1Q?*N*ja4WG4r9mw)8X%9}k6GYZWnj@tj2Zy%3%`|zir0?T zN|8lD^G6XwBM40NOL!n-g}?-QWk^zH3xG~c4>dllS%$hni+7av$g6_mEp!Z4x;&>! z7t|ALpx~xEQE=1ftcQM?2wsL^LlJ%<>j00%{4(9lK{7Dj{?ALGAo+K6nHmUz)oV^O z2g=8qIhf*8ga4I<|K&i~%=sT>6zW1B3#5mVX*t*FR=CliJM+@$MxaNVN}n9|UJMHa zSTAT00xG?m^Frm425@;l$g#okS^G(D!G}%A-|Tt+iHf`4?{XOk&d=-C$M~lJI7n^4*tv^1asV*e0ZS5!UqvP z?7mNNGd?s0{3$``UFU@HV>H0mg7`5{f_L^Z6i{)dU5O$9719$^f%-?EV*~r4zv-7F zbVtJ06;dMxoHyxbRw!(HA|z4&{k7oPceYaSEhlCrY*`rVbu;9{mz;A4{}wg>iK(a` z_x~lMARD5E?4$>%d~Z>eJZOjhCqC|u?{s(^Me9!a%6}s}#v&^fPnv8a6Ah>n&HfGq zR|;jc7KbAa=8Y0~LUE+YuFMVtchb<$(W-8$m%iIXSQr8CL>bKM5RWs+CXWwJ#1Y(G ziB)0mN4|CNgwaUiVREuKQXaV?j6mr;YyXkRL>p@@DeShQjph|P@aVgxYf>k>@ZMwH z^6tZ=!VUvDJZ-ak?`ioQ@W(DXPFKi-zb)HK(#?*7A1is=FDGZfYVQixFm+E6Q5GLA-czDLAS2T9$x@RNQ0xQX5{3SoCOYeg7!sHD(AosdO!`68cH zI!=0v7n>lxCE4b5{yi*dKy!Ua_H+oNjmX6tgMacA1eLdue<@*x%wEW=51f|iLeN-SnLvP!dc~! zvcOMPYUR5vIUen?B71Pk;mtYOW|`kuZhQMT+4S{?>iWd6Cfs%Zka6}c*JJ)etG2`* zih0A8?H4@0@ld&hz?qk8+kY)#iMihx{DAXwvIoc86=w`?-k*O&>a*ITu@=sI@7r9w zO9ei@xRF+MY9~)@O787J@l*3cBRW^B4jkQHab@v?GUbCpum0Zr)wMBl@sdX`<}Lj! zb9D6$TGqyQwx>CrjlVxhmJb?;JkhjOXW#%=^WxK91u2((?OO_$Of03=$>GP;ADVyb zy|+Mk=f|z7AF@&o-H!5)PrEIJUm?@-ZPUg_2k@F~Sza>x_aEeC;{G1$N1SLok$&J% zYTjPGPe$62TYD=^C5OWC%)8dKn+@RewKr`$pCDZPY@3PgRY~5^d5V1{>Kc3uOM*ruTkD65@{2t|kF`nhkoKnpi%dfvXe^GKI z{`|b}_Js<*ooDWduIIbJPab`FdDqp35c{^m`~Iw3qZ<{w6F08TShwuGZad%2a~FQ( zrmR%|L3}IDr?~UQPPxCfhj{Lmj1-DTI$0CvH9%byj`a>!!JMIo%Xl=-^aL5 znBS>N@_Qc};z7Ph6kF(1>*T(l$ zX!p!KS1`Ts5e>L^VQ+2u1){dN(!9m~xgXEXZ!Hiw-&wSP`mR>6QvQy=&G*NK-$Sn0 z=Y=h8^cU<~rGKZ^xllT`;sl{|qBWN#EMsR2=Z#t!efDpWJC+K|Q98?`ZaDv~UCb)z zu!3#>=o=@(x)z!fY0}v#u}J>MS>Q9c|NQ;VxpL-y#&KFwKlrTmoR2!rojmuu zeAAP4n9=0lzS1{W!rZUZO-t}!{>N|C%@r{BOGeWI-mS(|nEI~K+^KWF%`!c8E&G(| zzkR7?u9VrebXo=z(_336rr#>--08D1aprVBhZ)o92&tjE2<$Es(=zbCYak|voZzqj E2O~7?d;kCd literal 0 HcmV?d00001 diff --git a/docs/templates/~$farm_depreciation_manual_inputs.xlsx b/docs/templates/~$farm_depreciation_manual_inputs.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5a932052db2a5d1e1d32a453f59be330b8becc3b GIT binary patch literal 165 zcmWgj%}g%JFV0UZQSeVo%S=vH2rW)6QXm9G8GIQs8Il=_81fm4fjEt!gh7G9A4sQx R#Z!U2P@qgIP=x};5CA3W7%cz* literal 0 HcmV?d00001 diff --git a/docs/templates/~$kandang_house_type.xlsx b/docs/templates/~$kandang_house_type.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5a932052db2a5d1e1d32a453f59be330b8becc3b GIT binary patch literal 165 zcmWgj%}g%JFV0UZQSeVo%S=vH2rW)6QXm9G8GIQs8Il=_81fm4fjEt!gh7G9A4sQx R#Z!U2P@qgIP=x};5CA3W7%cz* literal 0 HcmV?d00001 diff --git a/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.down.sql b/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.down.sql similarity index 100% rename from internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.down.sql rename to internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.down.sql diff --git a/internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.up.sql b/internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.up.sql similarity index 100% rename from internal/database/migrations/20260416090000_create_farm_depreciation_snapshots.up.sql rename to internal/database/migrations/20260419134846_create_farm_depreciation_snapshots.up.sql diff --git a/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.down.sql b/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.down.sql similarity index 100% rename from internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.down.sql rename to internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.down.sql diff --git a/internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.up.sql b/internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.up.sql similarity index 100% rename from internal/database/migrations/20260417110000_create_farm_depreciation_manual_inputs.up.sql rename to internal/database/migrations/20260419135003_create_farm_depreciation_manual_inputs.up.sql diff --git a/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql b/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..down.sql similarity index 100% rename from internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql rename to internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..down.sql diff --git a/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql b/internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..up.sql similarity index 100% rename from internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql rename to internal/database/migrations/20260419135151_add_cutover_date_to_farm_depreciation_manual_inputs..up.sql From 30adbb6b8a8e9fbf7fcd82f19fd1b5d769a3940f Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 19 Apr 2026 21:24:29 +0700 Subject: [PATCH 8/8] fix do --- .../modules/marketing/validations/deliveryorder.validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index 4b7c1328..20719d55 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -5,7 +5,7 @@ type DeliveryProduct struct { Qty float64 `json:"qty" validate:"omitempty,gte=0"` UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"` AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"` - WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gt=0"` + WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gte=0"` TotalWeight *float64 `json:"total_weight" validate:"omitempty,gte=0"` TotalPrice *float64 `json:"total_price" validate:"omitempty,gte=0"` DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`