Compare commits

...

16 Commits

Author SHA1 Message Date
giovanni 5760bb6de8 adjust 2026-06-06 08:12:06 +07:00
giovanni 1ef32407f1 create api get depresiasi v2 2026-06-05 13:51:09 +07:00
Giovanni Gabriel Septriadi 6d2b6a0cb8 Merge branch 'rc/01' into 'production'
Rc/01

See merge request mbugroup/lti-api!598
2026-06-05 06:24:40 +00:00
Giovanni Gabriel Septriadi 4d3f654772 Merge branch 'fix/daily-checklist-fk' into 'rc/01'
Fix/daily checklist fk

See merge request mbugroup/lti-api!597
2026-06-05 06:20:41 +00:00
giovanni 2a101ed0db fix fk empty kandang to kandang_group 2026-06-05 12:58:08 +07:00
Giovanni Gabriel Septriadi 3e6ec39091 Merge branch 'rc/01' into 'production'
feat: add date range filter to marketing list API

See merge request mbugroup/lti-api!595
2026-06-04 17:30:25 +00:00
Giovanni Gabriel Septriadi 1b3642ef1d Merge branch 'feat/marketing-filter-range-date' into 'rc/01'
feat: add date range filter to marketing list API

See merge request mbugroup/lti-api!592
2026-06-04 16:57:32 +00:00
Giovanni Gabriel Septriadi 6b5a6a61b6 Merge branch 'feat/cut-over-depresiasi' into 'rc/01'
Feat/cut over depresiasi

See merge request mbugroup/lti-api!594
2026-06-04 16:57:11 +00:00
Giovanni Gabriel Septriadi d6304d9b39 Merge branch 'fix/recording-chickin' into 'rc/01'
Fix/recording chickin

See merge request mbugroup/lti-api!593
2026-06-04 16:51:24 +00:00
Giovanni Gabriel Septriadi b966777095 Merge branch 'feat/patch-chickindate' into 'rc/01'
Feat/patch chickindate

See merge request mbugroup/lti-api!591
2026-06-04 16:50:23 +00:00
giovanni f64839dfe1 add delete snapshoot if change chickin date 2026-06-04 23:46:25 +07:00
Giovanni Gabriel Septriadi 48870a60dc Merge branch 'feat/overselling-telur' into 'rc/01'
Feat/overselling telur

See merge request mbugroup/lti-api!590
2026-06-04 15:46:00 +00:00
giovanni a70a69a5be add validasi overselling telur 2026-06-03 10:26:40 +07:00
ValdiANS 981fb98248 fix: use soDate instead of deliveryDate for Delivery Order rows in marketing export
In the Excel export, Delivery Order rows were writing `group.DeliveryDate`
(the actual delivery date) to column B ("Tanggal"), while the web UI always
shows `so_date` for every row. This caused a visible mismatch — e.g. DO-01954
displayed "31 Mei 2026" on the web but "01-06-2026" in the exported file.

Changes:
- Remove the `doDate` variable from the DO branch; both the empty-deliveries
  fallback row and each per-delivery row now write `soDate` to column B,
  consistent with what the web shows
- Fix a pre-existing nil pointer dereference: `prod.ProductWarehouse.Warehouse`
  was accessed without a nil guard in the SO branch
- Update the export test to match the current 17-column layout (headers and
  row assertions were stale), and add a regression case that explicitly
  asserts a DO row with soDate=2026-05-31 / deliveryDate=2026-06-01 produces
  "31-05-2026" in column B

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:48:06 +07:00
ValdiANS 4b9e86427d feat: add date range filter to marketing list API
Added start_date, end_date, and filter_by query parameters to the
GET /api/marketing/ endpoint. Users can now filter marketing records
by a date range using either so_date (Sales Order date, default) or
created_at as the target column.

Changes:
- validation: added StartDate, EndDate (YYYY-MM-DD format), and
  FilterBy (oneof: so_date, created_at) to DeliveryOrderQuery struct
- controller: parse the three new query params in GetAll handler
- service: apply >=start / <end+1day date range filter in the query
  modifier using the existing utils.ParseDateRangeForQuery helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:19:52 +07:00
giovanni 4cb37e481b fix submit recording laying did not have chickin date 2026-06-02 10:39:50 +07:00
16 changed files with 476 additions and 23 deletions
@@ -0,0 +1,17 @@
BEGIN;
-- Revert the TELUR / TELUR_GRADE marketing over-sell block. Removing these rows
-- makes resolveOverConsume() fall back to the default allow rule again (the
-- post-20260313061525 behaviour). The reasons are unique to this migration, so
-- the DELETE only touches rows created here.
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IN ('TELUR', 'TELUR_GRADE')
AND reason IN (
'fifo_v2_exception_marketing_block_telur',
'fifo_v2_exception_marketing_block_telur_grade'
);
COMMIT;
@@ -0,0 +1,54 @@
BEGIN;
-- Restore the marketing over-sell block for TELUR and TELUR_GRADE only.
--
-- Migration 20260313061525 narrowed the MARKETING_OUT over-sell block to
-- flag_group_code='AYAM' and deactivated the global rule. That left TELUR /
-- TELUR_GRADE with no matching block, so resolveOverConsume() fell back to the
-- default rule 'fifo_v2_default_allow' (allow_overconsume=TRUE) and egg
-- Delivery Orders could over-sell silently into marketing_delivery_products.pending_qty.
--
-- These rules make resolveOverConsume('TELUR'|'TELUR_GRADE','MARKETING_OUT') = FALSE,
-- so an egg DO that exceeds available stock is REJECTED (ErrInsufficientStock)
-- instead of being recorded as pending. Scope is "Telur saja" — AYAM and
-- transfer behaviour are intentionally left unchanged.
--
-- NOTE: run the total_used reconciliation (cmd/reconcile-fifo-total-used) BEFORE
-- applying this in production. Enabling the block while phantom total_used still
-- inflates consumption would reject otherwise-valid egg orders.
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'TELUR', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR'
AND reason = 'fifo_v2_exception_marketing_block_telur'
);
UPDATE fifo_stock_v2_overconsume_rules
SET allow_overconsume = FALSE, priority = 20, is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR'
AND reason = 'fifo_v2_exception_marketing_block_telur';
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'TELUR_GRADE', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur_grade', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR_GRADE'
AND reason = 'fifo_v2_exception_marketing_block_telur_grade'
);
UPDATE fifo_stock_v2_overconsume_rules
SET allow_overconsume = FALSE, priority = 20, is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR_GRADE'
AND reason = 'fifo_v2_exception_marketing_block_telur_grade';
COMMIT;
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE daily_checklist_empty_kandangs
DROP CONSTRAINT IF EXISTS fk_dcek_kandang;
ALTER TABLE daily_checklist_empty_kandangs
ADD CONSTRAINT fk_dcek_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs (id) ON DELETE CASCADE;
COMMIT;
@@ -0,0 +1,23 @@
BEGIN;
ALTER TABLE daily_checklist_empty_kandangs
DROP CONSTRAINT IF EXISTS fk_dcek_kandang;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM daily_checklist_empty_kandangs dcek
LEFT JOIN kandang_groups kg ON kg.id = dcek.kandang_id
WHERE kg.id IS NULL
AND dcek.deleted_at IS NULL
) THEN
RAISE EXCEPTION 'Cannot fix FK: some kandang_id values do not exist in kandang_groups';
END IF;
END $$;
ALTER TABLE daily_checklist_empty_kandangs
ADD CONSTRAINT fk_dcek_kandang
FOREIGN KEY (kandang_id) REFERENCES kandang_groups (id) ON DELETE CASCADE;
COMMIT;
@@ -75,6 +75,9 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
WarehouseID: uint(c.QueryInt("warehouse_id", 0)), WarehouseID: uint(c.QueryInt("warehouse_id", 0)),
SortBy: sortBy, SortBy: sortBy,
SortOrder: sortOrder, SortOrder: sortOrder,
StartDate: strings.TrimSpace(c.Query("start_date", "")),
EndDate: strings.TrimSpace(c.Query("end_date", "")),
FilterBy: strings.TrimSpace(c.Query("filter_by", "")),
} }
if isAllExcelExportRequest(c) { if isAllExcelExportRequest(c) {
@@ -201,11 +201,6 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
for _, group := range item.DeliveryOrder { for _, group := range item.DeliveryOrder {
doNumber := safeMarketingExportText(group.DoNumber) doNumber := safeMarketingExportText(group.DoNumber)
doDate := "-"
if group.DeliveryDate != nil {
doDate = formatMarketingExportDate(*group.DeliveryDate)
}
gudang := "-" gudang := "-"
if group.Warehouse != nil { if group.Warehouse != nil {
gudang = safeMarketingExportText(group.Warehouse.Name) gudang = safeMarketingExportText(group.Warehouse.Name)
@@ -215,7 +210,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
row++ row++
r := strconv.Itoa(row) r := strconv.Itoa(row)
vals := map[string]interface{}{ vals := map[string]interface{}{
"A": doNumber, "B": doDate, "C": status, "D": customer, "E": salesPerson, "A": doNumber, "B": soDate, "C": status, "D": customer, "E": salesPerson,
"F": "-", "G": "-", "H": gudang, "I": "-", "J": "-", "K": "-", "F": "-", "G": "-", "H": gudang, "I": "-", "J": "-", "K": "-",
"L": "-", "M": "-", "N": "-", "O": "-", "L": "-", "M": "-", "N": "-", "O": "-",
"P": grandTotal, "Q": notes, "P": grandTotal, "Q": notes,
@@ -251,7 +246,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err := file.SetCellValue(sheet, "A"+r, doNumber); err != nil { if err := file.SetCellValue(sheet, "A"+r, doNumber); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "B"+r, doDate); err != nil { if err := file.SetCellValue(sheet, "B"+r, soDate); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "C"+r, status); err != nil { if err := file.SetCellValue(sheet, "C"+r, status); err != nil {
@@ -347,7 +342,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
} }
gudang := "-" gudang := "-"
if prod.ProductWarehouse != nil { if prod.ProductWarehouse != nil && prod.ProductWarehouse.Warehouse != nil {
gudang = safeMarketingExportText(prod.ProductWarehouse.Warehouse.Name) gudang = safeMarketingExportText(prod.ProductWarehouse.Warehouse.Name)
} }
@@ -15,6 +15,10 @@ import (
) )
func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) { func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
// DO item has soDate=2026-05-31 and deliveryDate=2026-06-01 to verify
// the export uses soDate (not deliveryDate) in column B.
deliveryDate := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
items := []dto.MarketingListDTO{ items := []dto.MarketingListDTO{
{ {
MarketingRelationDTO: dto.MarketingRelationDTO{ MarketingRelationDTO: dto.MarketingRelationDTO{
@@ -51,6 +55,22 @@ func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
Action: strPtr("REJECTED"), Action: strPtr("REJECTED"),
}, },
}, },
{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: "SO-00760",
SoDate: time.Date(2026, time.May, 31, 0, 0, 0, 0, time.UTC),
},
Customer: customerDTO.CustomerRelationDTO{Name: "CORDELA"},
DeliveryOrder: []dto.DeliveryGroupDTO{
{
DoNumber: "DO-01954",
DeliveryDate: &deliveryDate,
},
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Delivery Order",
},
},
} }
content, err := buildMarketingExportWorkbook(items) content, err := buildMarketingExportWorkbook(items)
@@ -69,9 +89,10 @@ func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
"B1": "Tanggal", "B1": "Tanggal",
"C1": "Status", "C1": "Status",
"D1": "Customer", "D1": "Customer",
"E1": "Grand Total", "E1": "Sales",
"F1": "Products", "G1": "Nama Produk",
"G1": "Notes", "P1": "Grand Total",
"Q1": "Catatan",
} }
for cell, expected := range expectedHeaders { for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(marketingExportSheetName, cell) got, err := file.GetCellValue(marketingExportSheetName, cell)
@@ -83,19 +104,25 @@ func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
} }
} }
// SO-00762: 3 products → rows 2, 3, 4
assertCellEquals(t, file, "A2", "SO-00762") assertCellEquals(t, file, "A2", "SO-00762")
assertCellEquals(t, file, "B2", "22-04-2026") assertCellEquals(t, file, "B2", "22-04-2026")
assertCellEquals(t, file, "C2", "Pengajuan") assertCellEquals(t, file, "C2", "Pengajuan")
assertCellEquals(t, file, "D2", "AJAT") assertCellEquals(t, file, "D2", "AJAT")
assertCellEquals(t, file, "E2", "Rp 5.206.200.000") assertCellEquals(t, file, "G2", "PAKAN GROWING CRUMBLE 8603 MALINDO")
assertCellEquals(t, file, "F2", "PAKAN GROWING CRUMBLE 8603 MALINDO, 295 GOLD PELLET") assertCellEquals(t, file, "Q2", "tes")
assertCellEquals(t, file, "G2", "tes")
assertCellEquals(t, file, "A3", "SO-00761") // SO-00761 (rejected): 1 product → row 5
assertCellEquals(t, file, "C3", "Ditolak") assertCellEquals(t, file, "A5", "SO-00761")
assertCellEquals(t, file, "E3", "Rp 75.000") assertCellEquals(t, file, "C5", "Ditolak")
assertCellEquals(t, file, "F3", "HS30 FOAM @20 LITER") assertCellEquals(t, file, "G5", "HS30 FOAM @20 LITER")
assertCellEquals(t, file, "G3", "-") assertCellEquals(t, file, "Q5", "-")
// DO-01954: column B must use soDate (31-05-2026), not deliveryDate (01-06-2026)
assertCellEquals(t, file, "A6", "DO-01954")
assertCellEquals(t, file, "B6", "31-05-2026")
assertCellEquals(t, file, "C6", "Delivery Order")
assertCellEquals(t, file, "D6", "CORDELA")
} }
func assertCellEquals(t *testing.T, file *excelize.File, cell, expected string) { func assertCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
@@ -321,6 +321,21 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
return db.Where("id = ?", params.MarketingId) return db.Where("id = ?", params.MarketingId)
} }
dateStart, dateEnd, dateErr := utils.ParseDateRangeForQuery(params.StartDate, params.EndDate)
if dateErr != nil {
return db.Where("1 = 0")
}
dateCol := "marketings.so_date"
if strings.TrimSpace(params.FilterBy) == "created_at" {
dateCol = "marketings.created_at"
}
if dateStart != nil {
db = db.Where(dateCol+" >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where(dateCol+" < ?", *dateEnd)
}
orderDir := "DESC" orderDir := "DESC"
if params.SortOrder != "" { if params.SortOrder != "" {
orderDir = strings.ToUpper(params.SortOrder) orderDir = strings.ToUpper(params.SortOrder)
@@ -964,7 +979,10 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
marketingProduct.ProductWarehouseId, marketingProduct.ProductWarehouseId,
resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt),
); err != nil { ); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) if errors.Is(err, fifoV2.ErrInsufficientStock) {
return fiber.NewError(fiber.StatusBadRequest, "Stok tidak mencukupi untuk memenuhi permintaan delivery order ini")
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal mengalokasikan stok: %v", err))
} }
refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil) refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil)
@@ -34,6 +34,9 @@ type DeliveryOrderQuery struct {
WarehouseID uint `query:"warehouse_id" validate:"omitempty,gt=0"` WarehouseID uint `query:"warehouse_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"` SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date created_at"`
} }
type DeliveryOrderApprove struct { type DeliveryOrderApprove struct {
@@ -2127,7 +2127,7 @@ func (s chickinService) UpdateChickInDate(ctx *fiber.Ctx, req *validation.Update
return fiber.NewError(fiber.StatusNotFound, "Project flock kandang tidak ditemukan") return fiber.NewError(fiber.StatusNotFound, "Project flock kandang tidak ditemukan")
} }
return s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.Repository.UpdateChickInDateByProjectFlockKandangID(ctx.Context(), tx, req.ProjectFlockKandangId, newDate); err != nil { if err := s.Repository.UpdateChickInDateByProjectFlockKandangID(ctx.Context(), tx, req.ProjectFlockKandangId, newDate); err != nil {
return err return err
} }
@@ -2139,5 +2139,10 @@ func (s chickinService) UpdateChickInDate(ctx *fiber.Ctx, req *validation.Update
WHERE project_flock_kandangs_id = ? WHERE project_flock_kandangs_id = ?
AND deleted_at IS NULL AND deleted_at IS NULL
`, req.ChickInDate, req.ProjectFlockKandangId).Error `, req.ChickInDate, req.ProjectFlockKandangId).Error
}) }); err != nil {
return err
}
s.invalidateDepreciationSnapshots(ctx.Context(), nil, []uint{req.ProjectFlockKandangId}, newDate)
return nil
} }
@@ -409,7 +409,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return nil, err return nil, err
} }
if err := s.ChickinSvc.EnsureChickInExists(ctx, pfk.Id); err != nil { if err := s.ChickinSvc.EnsureChickInExists(ctx, pfk.Id); err != nil {
return nil, err if !isLaying {
return nil, err
}
// LAYING fallback: kandang laying tidak punya project_chickins sendiri —
// populasinya dari laying transfer. Cek apakah ada executed laying transfer.
if fallbackErr := s.ensureLayingTransferExecutedForKandang(ctx, pfk.Id); fallbackErr != nil {
return nil, err
}
} }
if s.ProductionStandardSvc != nil { if s.ProductionStandardSvc != nil {
if err := s.ProductionStandardSvc.EnsureWeekStart(ctx, pfk.ProjectFlock.ProductionStandardId, category); err != nil { if err := s.ProductionStandardSvc.EnsureWeekStart(ctx, pfk.ProjectFlock.ProductionStandardId, category); err != nil {
@@ -3998,6 +4005,27 @@ func (s *recordingService) reflowRollbackRecordingInventory(ctx context.Context,
return nil return nil
} }
func (s *recordingService) ensureLayingTransferExecutedForKandang(ctx context.Context, pfkID uint) error {
var count int64
err := s.Repository.DB().WithContext(ctx).
Table("laying_transfer_targets ltt").
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Where("ltt.target_project_flock_kandang_id = ?", pfkID).
Where("ltt.deleted_at IS NULL").
Where("lt.deleted_at IS NULL").
Where("lt.executed_at IS NOT NULL").
Where("ltt.total_qty > 0").
Count(&count).Error
if err != nil {
s.Log.Errorf("Failed to check executed laying transfer for pfk_id=%d: %+v", pfkID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa transfer laying")
}
if count == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Kandang laying belum memiliki transfer laying yang telah dieksekusi sehingga belum dapat membuat recording")
}
return nil
}
func (s *recordingService) requireFIFO() error { func (s *recordingService) requireFIFO() error {
if s.FifoStockV2Svc == nil { if s.FifoStockV2Svc == nil {
s.Log.Errorf("FIFO v2 service is not available for recording operations") s.Log.Errorf("FIFO v2 service is not available for recording operations")
@@ -152,6 +152,29 @@ func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error {
return ctx.Status(fiber.StatusOK).JSON(resp) return ctx.Status(fiber.StatusOK).JSON(resp)
} }
func (c *RepportController) GetExpenseDepreciationV2(ctx *fiber.Ctx) error {
rows, meta, err := c.RepportService.GetExpenseDepreciationV2(ctx)
if err != nil {
return err
}
resp := struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Meta dto.ExpenseDepreciationV2MetaDTO `json:"meta"`
Data []dto.ExpenseDepreciationV2RowDTO `json:"data"`
}{
Code: fiber.StatusOK,
Status: "success",
Message: "Get expense depreciation report v2 successfully",
Meta: *meta,
Data: rows,
}
return ctx.Status(fiber.StatusOK).JSON(resp)
}
func (c *RepportController) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) error { func (c *RepportController) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) error {
rows, meta, err := c.RepportService.GetExpenseDepreciationManualInputs(ctx) rows, meta, err := c.RepportService.GetExpenseDepreciationManualInputs(ctx)
if err != nil { if err != nil {
@@ -40,6 +40,29 @@ type ExpenseDepreciationManualInputRowDTO struct {
Note *string `json:"note"` Note *string `json:"note"`
} }
type ExpenseDepreciationV2MetaDTO struct {
ProjectFlockID int64 `json:"project_flock_id"`
FarmName string `json:"farm_name"`
LocationID int64 `json:"location_id"`
Period string `json:"period"`
Limit int `json:"limit"`
TotalDays int `json:"total_days"`
}
type ExpenseDepreciationV2RowDTO struct {
Date string `json:"date"`
DepreciationPercentEffective float64 `json:"depreciation_percent_effective"`
DepreciationValue float64 `json:"depreciation_value"`
PulletCostDayNTotal float64 `json:"pullet_cost_day_n_total"`
MultiplicationPercentage float64 `json:"multiplication_percentage"`
DayN int `json:"day_n"`
ChickinDate string `json:"chickin_date"`
TotalValuePulletAfterDepreciation float64 `json:"total_value_pullet_after_depreciation"`
StandardEffectiveDate string `json:"standard_effective_date,omitempty"`
TotalPopulation float64 `json:"total_population"`
Components any `json:"components"`
}
func NewExpenseDepreciationFiltersDTO(area, location, projectFlockID, period string) ExpenseDepreciationFiltersDTO { func NewExpenseDepreciationFiltersDTO(area, location, projectFlockID, period string) ExpenseDepreciationFiltersDTO {
return ExpenseDepreciationFiltersDTO{ return ExpenseDepreciationFiltersDTO{
AreaID: area, AreaID: area,
+1
View File
@@ -17,6 +17,7 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService
route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense)
route.Get("/expense/depreciation", ctrl.GetExpenseDepreciation) route.Get("/expense/depreciation", ctrl.GetExpenseDepreciation)
route.Get("/expense/v2/depreciation", ctrl.GetExpenseDepreciationV2)
route.Get("/expense/depreciation/manual-inputs", ctrl.GetExpenseDepreciationManualInputs) route.Get("/expense/depreciation/manual-inputs", ctrl.GetExpenseDepreciationManualInputs)
route.Put("/expense/depreciation/manual-inputs", ctrl.UpsertExpenseDepreciationManualInput) route.Put("/expense/depreciation/manual-inputs", ctrl.UpsertExpenseDepreciationManualInput)
route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing) route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing)
@@ -43,6 +43,7 @@ import (
type RepportService interface { type RepportService interface {
GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error)
GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error)
GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationV2RowDTO, *dto.ExpenseDepreciationV2MetaDTO, error)
GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error)
UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error)
GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error)
@@ -355,6 +356,182 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe
return rows[offset:end], meta, nil return rows[offset:end], meta, nil
} }
func (s *repportService) GetExpenseDepreciationV2(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationV2RowDTO, *dto.ExpenseDepreciationV2MetaDTO, error) {
params, err := s.parseExpenseDepreciationV2Query(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")
}
if s.HppCostRepo == nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "hpp cost repository is not configured")
}
if s.HppV2Svc == nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "hpp v2 service 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")
}
limit := params.Limit
if limit <= 0 {
limit = 10
}
farmID := uint(params.ProjectFlockID)
kandangIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx.Context(), farmID)
if err != nil {
return nil, nil, err
}
if len(kandangIDs) == 0 {
return nil, nil, fiber.NewError(fiber.StatusNotFound, "project flock has no kandangs")
}
var farmName string
if err := s.db.WithContext(ctx.Context()).
Table("project_flocks").
Select("flock_name").
Where("id = ? AND deleted_at IS NULL", farmID).
Scan(&farmName).Error; err != nil {
return nil, nil, err
}
if farmName == "" {
return nil, nil, fiber.NewError(fiber.StatusNotFound, "project flock not found")
}
rows := make([]dto.ExpenseDepreciationV2RowDTO, 0, limit)
actualDays := 0
for i := 0; i < limit; i++ {
dayDate := periodDate.AddDate(0, 0, i)
dayStr := dayDate.Format("2006-01-02")
var totalDepreciationValue float64
var totalPulletCostDayN float64
var totalPopulation float64
var allKandangComponents []depreciationKandangComponent
for _, kandangID := range kandangIDs {
breakdown, err := s.HppV2Svc.CalculateHppBreakdown(kandangID, &dayDate)
if err != nil {
return nil, nil, err
}
if breakdown == nil {
continue
}
depreciationComponent := hppV2FindDepreciationComponent(breakdown)
if depreciationComponent == nil {
continue
}
for _, part := range depreciationComponent.Parts {
if part.Total <= 0 {
continue
}
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"),
MultiplicationPercentage: hppV2DetailFloat(part.Details, "multiplication_percentage"),
PulletCostDayN: hppV2DetailFloat(part.Details, "pullet_cost_day_n"),
DepreciationValue: part.Total,
TotalValuePulletAfterDepreciation: hppV2DetailFloat(part.Details, "total_value_pullet_after_depreciation"),
DepreciationSource: part.Code,
OriginDate: hppV2DetailString(part.Details, "origin_date"),
ChickinDate: hppV2DetailString(part.Details, "origin_date"),
StandardEffectiveDate: hppV2DetailString(part.Details, "standard_effective_date"),
Population: hppV2DetailFloat(part.Details, "kandang_population"),
}
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
totalPopulation += component.Population
allKandangComponents = append(allKandangComponents, component)
}
}
effectivePercent := approvalService.CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN)
components := depreciationFarmComponents{
KandangCount: len(allKandangComponents),
TotalPopulation: totalPopulation,
Kandang: allKandangComponents,
}
componentsJSON, _ := json.Marshal(components)
multiplicationPercentage, dayN, chickinDate, standardEffectiveDate := depreciationSnapshotInfo(parseSnapshotComponents(componentsJSON))
rows = append(rows, dto.ExpenseDepreciationV2RowDTO{
Date: dayStr,
DepreciationPercentEffective: effectivePercent,
DepreciationValue: totalDepreciationValue,
PulletCostDayNTotal: totalPulletCostDayN,
MultiplicationPercentage: multiplicationPercentage,
DayN: dayN,
ChickinDate: chickinDate,
TotalValuePulletAfterDepreciation: totalPulletCostDayN - totalDepreciationValue,
StandardEffectiveDate: standardEffectiveDate,
TotalPopulation: totalPopulation,
Components: parseSnapshotComponents(componentsJSON),
})
actualDays++
}
meta := &dto.ExpenseDepreciationV2MetaDTO{
ProjectFlockID: params.ProjectFlockID,
FarmName: farmName,
LocationID: params.LocationID,
Period: params.Period,
Limit: limit,
TotalDays: actualDays,
}
return rows, meta, nil
}
func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) { func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) {
params, filters, err := s.parseExpenseDepreciationQuery(ctx) params, filters, err := s.parseExpenseDepreciationQuery(ctx)
if err != nil { if err != nil {
@@ -3025,6 +3202,45 @@ func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validat
return params, filters, nil return params, filters, nil
} }
func (s *repportService) parseExpenseDepreciationV2Query(ctx *fiber.Ctx) (*validation.ExpenseDepreciationV2Query, error) {
limit := ctx.QueryInt("limit", 10)
if limit < 1 {
limit = 10
}
period := strings.TrimSpace(ctx.Query("period", ""))
locationID := ctx.QueryInt("location_id", 0)
projectFlockID := ctx.QueryInt("project_flock_id", 0)
locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB())
if err != nil {
return nil, err
}
if locationScope.Restrict && locationID > 0 {
allowed := toInt64Slice(locationScope.IDs)
if len(allowed) == 0 {
return nil, fiber.NewError(fiber.StatusForbidden, "no location access")
}
found := false
for _, id := range allowed {
if id == int64(locationID) {
found = true
break
}
}
if !found {
return nil, fiber.NewError(fiber.StatusForbidden, "location not in scope")
}
}
return &validation.ExpenseDepreciationV2Query{
Limit: limit,
Period: period,
LocationID: int64(locationID),
ProjectFlockID: int64(projectFlockID),
}, nil
}
func parseCommaSeparatedInt64s(raw string) ([]int64, error) { func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -93,6 +93,13 @@ type ExpenseDepreciationQuery struct {
LocationIDs []int64 `query:"-"` LocationIDs []int64 `query:"-"`
} }
type ExpenseDepreciationV2Query struct {
Limit int `query:"limit" validate:"omitempty,min=1,max=90"`
Period string `query:"period" validate:"required,datetime=2006-01-02"`
LocationID int64 `query:"location_id" validate:"omitempty,gt=0"`
ProjectFlockID int64 `query:"project_flock_id" validate:"required,gt=0"`
}
type ExpenseDepreciationManualInputUpsert struct { type ExpenseDepreciationManualInputUpsert struct {
ProjectFlockID uint `json:"project_flock_id" validate:"required,gt=0"` ProjectFlockID uint `json:"project_flock_id" validate:"required,gt=0"`
TotalCost float64 `json:"total_cost" validate:"required,gte=0"` TotalCost float64 `json:"total_cost" validate:"required,gte=0"`