Compare commits

...

10 Commits

Author SHA1 Message Date
giovanni f64839dfe1 add delete snapshoot if change chickin date 2026-06-04 23:46:25 +07:00
giovanni 0ff720453f add api for edit chickin date 2026-06-03 11:56:32 +07:00
Giovanni Gabriel Septriadi ef2f9568ad Merge branch 'rc/01' into 'production'
Rc/01

See merge request mbugroup/lti-api!583
2026-06-01 15:01:07 +00:00
Giovanni Gabriel Septriadi badbe4086a Merge branch 'fix/reconcile-fifo' into 'rc/01'
add command to fix reconcile fifo; fix fifo stock v2

See merge request mbugroup/lti-api!582
2026-06-01 14:46:45 +00:00
Giovanni Gabriel Septriadi 6528739bfd Merge branch 'feat/export-marketing' into 'rc/01'
Feat/export marketing and recording

See merge request mbugroup/lti-api!581
2026-06-01 14:45:41 +00:00
giovanni 68bddd5c78 adjust response list marketing add grand total so dan do 2026-05-31 16:38:22 +07:00
giovanni 90efd0ba5a add command to fix reconcile fifo; fix fifo stock v2 2026-05-31 16:25:16 +07:00
giovanni bfef144668 add filter warehouse to marketing;add detail export recording egg; adjust format export marketing 2026-05-31 16:23:22 +07:00
Giovanni Gabriel Septriadi 09b1f19d19 Merge branch 'rc/01' into 'production'
Rc/01

See merge request mbugroup/lti-api!578
2026-05-30 03:14:16 +00:00
Giovanni Gabriel Septriadi 672f80a3ba Merge branch 'fix/week-recording' into 'rc/01'
Fix/week recording

See merge request mbugroup/lti-api!577
2026-05-30 03:05:50 +00:00
17 changed files with 1051 additions and 101 deletions
+484
View File
@@ -0,0 +1,484 @@
// Command reconcile-fifo-total-used memperbaiki "phantom total_used" pada
// stockable lot FIFO v2 (recording_eggs, stock_transfer_details, dst.).
//
// LATAR BELAKANG
// Sebelum fix di population_allocation.go, ReleaseByUsable melepas SEMUA alokasi
// CONSUME sebuah usable (termasuk RECORDING_EGG / STOCK_TRANSFER_IN) tanpa
// men-decrement total_used stockable-nya. Akibatnya total_used "nyangkut" lebih
// besar dari jumlah alokasi ACTIVE yang membackup-nya (phantom) → available
// dihitung 0 padahal stok fisik ada → Delivery Order telur nyangkut di pending.
//
// PERBAIKAN
// Sumber kebenaran konsumsi = stock_allocations status ACTIVE & purpose CONSUME.
// Command ini menyetel ulang total_used setiap lot = SUM(alokasi ACTIVE CONSUME
// untuk lot itu), lalu menjalankan FIFO v2 Reflow per (PW, flag group) sehingga
// pending dialokasi ulang ke stok yang kini available dan product_warehouses.qty
// dihitung ulang.
//
// PENTING: jalankan command ini SETELAH fix kode (population_allocation.go)
// ter-deploy, dan SEBELUM mengaktifkan blok over-sell telur.
//
// Cara pakai:
//
// go run ./cmd/reconcile-fifo-total-used/ -pw=1292 # dry-run 1 PW
// go run ./cmd/reconcile-fifo-total-used/ -pw=1292 -apply # apply 1 PW
// go run ./cmd/reconcile-fifo-total-used/ -pw=1292,1296,1268 -apply
// go run ./cmd/reconcile-fifo-total-used/ -pw=1292 -apply -output=json
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"regexp"
"strconv"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
const (
outputTable = "table"
outputJSON = "json"
)
var identifierRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
type options struct {
Apply bool
Output string
DBSSLMode string
PWs []uint
}
// stockableRule menggambarkan satu jenis stockable (mis. RECORDING_EGG) beserta
// tabel & kolom yang dipakai FIFO v2 untuk melacak stok masuk.
type stockableRule struct {
LegacyTypeKey string
SourceTable string
SourceIDColumn string
UsedQuantityCol string
ProductWarehouseCol string
QuantityCol string
ScopeSQL string
}
type pwResult struct {
ProductWarehouseID uint `json:"product_warehouse_id"`
Product string `json:"product"`
Warehouse string `json:"warehouse"`
FlagGroups []string `json:"flag_groups"`
QtyBefore float64 `json:"qty_before"`
TotalUsedBefore float64 `json:"total_used_before"`
ActiveConsume float64 `json:"active_consume"`
Phantom float64 `json:"phantom"`
PendingBefore float64 `json:"pending_before"`
QtyAfter float64 `json:"qty_after,omitempty"`
PendingAfter float64 `json:"pending_after,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
type runSummary struct {
Mode string `json:"mode"`
TargetPWs []uint `json:"target_pws"`
Results []pwResult `json:"results"`
DurationSeconds float64 `json:"duration_seconds"`
OverallStatus string `json:"overall_status"`
}
func main() {
opts, err := parseFlags()
if err != nil {
log.Fatalf("invalid flags: %v", err)
}
if opts.DBSSLMode != "" {
config.DBSSLMode = opts.DBSSLMode
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
// Quiet the per-query GORM logging; this command emits its own summary and
// the reflow step would otherwise produce a very noisy query log.
db = db.Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
logger := logrus.New()
logger.SetLevel(logrus.WarnLevel)
svc := commonSvc.NewFifoStockV2Service(db, logger)
start := time.Now()
stockableRules, err := loadStockableRules(ctx, db)
if err != nil {
log.Fatalf("failed to load stockable route rules: %v", err)
}
pendingRules, err := loadUsablePendingRules(ctx, db)
if err != nil {
log.Fatalf("failed to load usable route rules: %v", err)
}
summary := runSummary{
Mode: modeLabel(opts.Apply),
TargetPWs: opts.PWs,
OverallStatus: "PASS",
}
for _, pw := range opts.PWs {
res := reconcilePW(ctx, db, svc, pw, stockableRules, pendingRules, opts.Apply)
if res.Status == "FAIL" {
summary.OverallStatus = "FAIL"
}
summary.Results = append(summary.Results, res)
}
summary.DurationSeconds = time.Since(start).Seconds()
render(opts.Output, summary)
if !opts.Apply {
fmt.Println("\nDry-run only. Re-run with -apply to reset total_used and reflow the PW(s) above.")
}
if summary.OverallStatus == "FAIL" {
os.Exit(1)
}
}
// reconcilePW mengukur kondisi PW, lalu (jika -apply) menyetel ulang total_used
// tiap lot dan menjalankan reflow, semuanya dalam satu transaksi.
func reconcilePW(
ctx context.Context,
db *gorm.DB,
svc commonSvc.FifoStockV2Service,
pw uint,
stockableRules []stockableRule,
pendingRules []stockableRule,
apply bool,
) pwResult {
res := pwResult{ProductWarehouseID: pw, Status: "OK"}
if name, wh, err := loadPWIdentity(ctx, db, pw); err != nil {
res.Status = "FAIL"
res.Error = fmt.Sprintf("load identity: %v", err)
return res
} else {
res.Product, res.Warehouse = name, wh
}
flagGroups, err := loadFlagGroups(ctx, db, pw)
if err != nil {
res.Status = "FAIL"
res.Error = fmt.Sprintf("load flag groups: %v", err)
return res
}
res.FlagGroups = flagGroups
res.QtyBefore, _ = loadQty(ctx, db, pw)
res.TotalUsedBefore, _ = sumStockableUsed(ctx, db, pw, stockableRules)
res.ActiveConsume, _ = loadActiveConsume(ctx, db, pw)
res.PendingBefore, _ = sumPending(ctx, db, pw, pendingRules)
res.Phantom = res.TotalUsedBefore - res.ActiveConsume
if !apply {
return res
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, rule := range stockableRules {
if err := recomputeUsed(ctx, tx, rule, pw); err != nil {
return fmt.Errorf("recompute %s: %w", rule.LegacyTypeKey, err)
}
}
for _, fg := range flagGroups {
if _, err := svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: fg,
ProductWarehouseID: pw,
Tx: tx,
}); err != nil {
return fmt.Errorf("reflow flag_group=%s: %w", fg, err)
}
}
return nil
})
if err != nil {
res.Status = "FAIL"
res.Error = err.Error()
return res
}
res.QtyAfter, _ = loadQty(ctx, db, pw)
res.PendingAfter, _ = sumPending(ctx, db, pw, pendingRules)
return res
}
func recomputeUsed(ctx context.Context, tx *gorm.DB, rule stockableRule, pw uint) error {
q := fmt.Sprintf(`
UPDATE %s t
SET %s = COALESCE((
SELECT SUM(sa.qty) FROM stock_allocations sa
WHERE sa.stockable_type = ?
AND sa.stockable_id = t.%s
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
), 0)
WHERE t.%s = ?`, rule.SourceTable, rule.UsedQuantityCol, rule.SourceIDColumn, rule.ProductWarehouseCol)
if strings.TrimSpace(rule.ScopeSQL) != "" {
q += " AND (" + rule.ScopeSQL + ")"
}
return tx.WithContext(ctx).Exec(q, rule.LegacyTypeKey, pw).Error
}
// ---- loaders ----
func loadStockableRules(ctx context.Context, db *gorm.DB) ([]stockableRule, error) {
type row struct {
LegacyTypeKey string `gorm:"column:legacy_type_key"`
SourceTable string `gorm:"column:source_table"`
SourceIDColumn string `gorm:"column:source_id_column"`
UsedQuantityCol string `gorm:"column:used_quantity_col"`
ProductWarehouseCol string `gorm:"column:product_warehouse_col"`
QuantityCol string `gorm:"column:quantity_col"`
ScopeSQL string `gorm:"column:scope_sql"`
}
var rows []row
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Select("DISTINCT legacy_type_key, source_table, source_id_column, COALESCE(used_quantity_col,'') AS used_quantity_col, product_warehouse_col, COALESCE(quantity_col,'') AS quantity_col, COALESCE(scope_sql,'') AS scope_sql").
Where("lane = ? AND is_active = TRUE", "STOCKABLE").
Where("used_quantity_col IS NOT NULL AND used_quantity_col <> ''").
Scan(&rows).Error
if err != nil {
return nil, err
}
out := make([]stockableRule, 0, len(rows))
seen := map[string]bool{}
for _, r := range rows {
if !validIdentifiers(r.SourceTable, r.SourceIDColumn, r.UsedQuantityCol, r.ProductWarehouseCol) {
return nil, fmt.Errorf("unsafe identifier in route rule %s (table=%s used=%s pw=%s)", r.LegacyTypeKey, r.SourceTable, r.UsedQuantityCol, r.ProductWarehouseCol)
}
key := r.LegacyTypeKey + "|" + r.SourceTable + "|" + r.UsedQuantityCol + "|" + r.ProductWarehouseCol
if seen[key] {
continue
}
seen[key] = true
out = append(out, stockableRule(r))
}
return out, nil
}
func loadUsablePendingRules(ctx context.Context, db *gorm.DB) ([]stockableRule, error) {
type row struct {
SourceTable string `gorm:"column:source_table"`
ProductWarehouseCol string `gorm:"column:product_warehouse_col"`
PendingCol string `gorm:"column:pending_quantity_col"`
ScopeSQL string `gorm:"column:scope_sql"`
}
var rows []row
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Select("DISTINCT source_table, product_warehouse_col, pending_quantity_col, COALESCE(scope_sql,'') AS scope_sql").
Where("lane = ? AND is_active = TRUE", "USABLE").
Where("pending_quantity_col IS NOT NULL AND pending_quantity_col <> ''").
Scan(&rows).Error
if err != nil {
return nil, err
}
out := make([]stockableRule, 0, len(rows))
seen := map[string]bool{}
for _, r := range rows {
if !validIdentifiers(r.SourceTable, r.ProductWarehouseCol, r.PendingCol) {
return nil, fmt.Errorf("unsafe identifier in usable rule (table=%s pw=%s pending=%s)", r.SourceTable, r.ProductWarehouseCol, r.PendingCol)
}
key := r.SourceTable + "|" + r.PendingCol + "|" + r.ProductWarehouseCol
if seen[key] {
continue
}
seen[key] = true
out = append(out, stockableRule{
SourceTable: r.SourceTable,
ProductWarehouseCol: r.ProductWarehouseCol,
UsedQuantityCol: r.PendingCol, // reuse field as the column to SUM
ScopeSQL: r.ScopeSQL,
})
}
return out, nil
}
func loadPWIdentity(ctx context.Context, db *gorm.DB, pw uint) (string, string, error) {
type row struct {
Product string `gorm:"column:product"`
Warehouse string `gorm:"column:warehouse"`
}
var out row
err := db.WithContext(ctx).
Table("product_warehouses pw").
Select("p.name AS product, w.name AS warehouse").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("pw.id = ?", pw).
Take(&out).Error
return out.Product, out.Warehouse, err
}
func loadFlagGroups(ctx context.Context, db *gorm.DB, pw uint) ([]string, error) {
var groups []string
err := db.WithContext(ctx).
Table("stock_allocations").
Distinct("flag_group_code").
Where("product_warehouse_id = ? AND flag_group_code IS NOT NULL AND flag_group_code <> ''", pw).
Order("flag_group_code ASC").
Scan(&groups).Error
return groups, err
}
func loadQty(ctx context.Context, db *gorm.DB, pw uint) (float64, error) {
var v float64
err := db.WithContext(ctx).
Table("product_warehouses").
Select("COALESCE(qty,0)").
Where("id = ?", pw).
Scan(&v).Error
return v, err
}
func loadActiveConsume(ctx context.Context, db *gorm.DB, pw uint) (float64, error) {
var v float64
err := db.WithContext(ctx).
Table("stock_allocations").
Select("COALESCE(SUM(qty),0)").
Where("product_warehouse_id = ? AND status = 'ACTIVE' AND allocation_purpose = 'CONSUME'", pw).
Scan(&v).Error
return v, err
}
func sumStockableUsed(ctx context.Context, db *gorm.DB, pw uint, rules []stockableRule) (float64, error) {
total := 0.0
for _, rule := range rules {
v, err := sumColumn(ctx, db, rule.SourceTable, rule.UsedQuantityCol, rule.ProductWarehouseCol, rule.ScopeSQL, pw)
if err != nil {
return total, err
}
total += v
}
return total, nil
}
func sumPending(ctx context.Context, db *gorm.DB, pw uint, rules []stockableRule) (float64, error) {
total := 0.0
for _, rule := range rules {
v, err := sumColumn(ctx, db, rule.SourceTable, rule.UsedQuantityCol, rule.ProductWarehouseCol, rule.ScopeSQL, pw)
if err != nil {
return total, err
}
total += v
}
return total, nil
}
func sumColumn(ctx context.Context, db *gorm.DB, table, col, pwCol, scope string, pw uint) (float64, error) {
q := fmt.Sprintf("SELECT COALESCE(SUM(%s),0) FROM %s WHERE %s = ?", col, table, pwCol)
if strings.TrimSpace(scope) != "" {
q += " AND (" + scope + ")"
}
var v float64
err := db.WithContext(ctx).Raw(q, pw).Scan(&v).Error
return v, err
}
// ---- flags / render ----
func parseFlags() (*options, error) {
var opts options
var pwsRaw string
flag.BoolVar(&opts.Apply, "apply", false, "Apply the reconciliation (omit for dry-run)")
flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json")
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Database sslmode override")
flag.StringVar(&pwsRaw, "pw", "", "Comma-separated product_warehouse ids to reconcile (required)")
flag.Parse()
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
if opts.Output == "" {
opts.Output = outputTable
}
if opts.Output != outputTable && opts.Output != outputJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
pwsRaw = strings.TrimSpace(pwsRaw)
if pwsRaw == "" {
return nil, fmt.Errorf("-pw is required (e.g. -pw=1292 or -pw=1292,1296)")
}
for _, part := range strings.Split(pwsRaw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseUint(part, 10, 64)
if err != nil || id == 0 {
return nil, fmt.Errorf("invalid product_warehouse id %q", part)
}
opts.PWs = append(opts.PWs, uint(id))
}
if len(opts.PWs) == 0 {
return nil, fmt.Errorf("no valid product_warehouse ids parsed from -pw")
}
return &opts, nil
}
func validIdentifiers(ids ...string) bool {
for _, id := range ids {
if !identifierRe.MatchString(id) {
return false
}
}
return true
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY_RUN"
}
func render(mode string, summary runSummary) {
if mode == outputJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(summary)
return
}
fmt.Printf("=== Reconcile FIFO total_used ===\n")
fmt.Printf("Mode : %s\n", summary.Mode)
for _, r := range summary.Results {
fmt.Printf("\n--- PW %d (%s @ %s) [%s] ---\n", r.ProductWarehouseID, r.Product, r.Warehouse, r.Status)
if r.Error != "" {
fmt.Printf("ERROR : %s\n", r.Error)
}
fmt.Printf("Flag groups : %s\n", strings.Join(r.FlagGroups, ", "))
fmt.Printf("qty (before) : %.3f\n", r.QtyBefore)
fmt.Printf("Σ total_used : %.3f\n", r.TotalUsedBefore)
fmt.Printf("Σ active CONSUME: %.3f\n", r.ActiveConsume)
fmt.Printf("PHANTOM : %.3f (total_used yang akan dilepas)\n", r.Phantom)
fmt.Printf("pending (before): %.3f\n", r.PendingBefore)
if summary.Mode == "APPLY" && r.Status == "OK" {
fmt.Printf("qty (after) : %.3f\n", r.QtyAfter)
fmt.Printf("pending (after) : %.3f\n", r.PendingAfter)
}
}
fmt.Printf("\nDuration : %.2fs\n", summary.DurationSeconds)
fmt.Printf("Overall status : %s\n", summary.OverallStatus)
}
@@ -45,7 +45,16 @@ func ReleasePopulationConsumptionByUsable(
} }
} }
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil) // Only release the PROJECT_FLOCK_POPULATION allocations here. Releasing the
// other CONSUME allocations of this usable (RECORDING_EGG, STOCK_TRANSFER_IN,
// PURCHASE_ITEMS, etc.) would orphan their stockable total_used because this
// path only restores total_used_qty for population lots — leaving the FIFO
// stock counters permanently inflated (phantom stock). Those stock
// allocations are owned by the FIFO Reflow/Rollback path, which decrements
// total_used correctly via adjustStockableUsedQuantity.
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, func(db *gorm.DB) *gorm.DB {
return db.Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String())
})
} }
func AllocatePopulationConsumption( func AllocatePopulationConsumption(
@@ -72,6 +72,7 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
MarketingId: uint(c.QueryInt("marketing_id", 0)), MarketingId: uint(c.QueryInt("marketing_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)), ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)), ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
WarehouseID: uint(c.QueryInt("warehouse_id", 0)),
SortBy: sortBy, SortBy: sortBy,
SortOrder: sortOrder, SortOrder: sortOrder,
} }
@@ -70,23 +70,26 @@ func buildMarketingExportWorkbook(items []dto.MarketingListDTO) ([]byte, error)
} }
func setMarketingExportColumns(file *excelize.File, sheet string) error { func setMarketingExportColumns(file *excelize.File, sheet string) error {
// AQ = 17 columns
// E = Sales (new), H = Gudang (new), Satuan (old I) removed
columnWidths := map[string]float64{ columnWidths := map[string]float64{
"A": 16, "A": 16, // No. Order
"B": 14, "B": 14, // Tanggal
"C": 18, "C": 18, // Status
"D": 20, "D": 20, // Customer
"E": 14, "E": 20, // Sales (new)
"F": 40, "F": 14, // Tipe
"G": 10, "G": 40, // Nama Produk
"H": 12, "H": 20, // Gudang (new)
"I": 12, "I": 10, // Week
"J": 12, "J": 12, // Jumlah
"K": 16, "K": 12, // Qty Peti
"L": 16, "L": 16, // Berat Rata-rata (kg)
"M": 18, "M": 16, // Total Berat (kg)
"N": 18, "N": 18, // Harga Satuan
"O": 18, "O": 18, // Total Harga
"P": 24, "P": 18, // Grand Total
"Q": 24, // Catatan
} }
for col, width := range columnWidths { for col, width := range columnWidths {
@@ -108,18 +111,19 @@ func setMarketingExportHeaders(file *excelize.File, sheet string) error {
"Tanggal", // B "Tanggal", // B
"Status", // C "Status", // C
"Customer", // D "Customer", // D
"Tipe", // E "Sales", // E (new)
"Nama Produk", // F "Tipe", // F
"Week", // G "Nama Produk", // G
"Jumlah", // H "Gudang", // H (new)
"Satuan", // I "Week", // I
"Qty Peti", // J "Jumlah Butir", // J
"Berat Rata-rata (kg)", // K "Qty Peti", // K
"Total Berat (kg)", // L "Berat Rata-rata (kg)", // L
"Harga Satuan", // M "Total Berat (kg)", // M
"Total Harga", // N "Harga Satuan", // N
"Grand Total", // O "Total Harga", // O
"Catatan", // P "Grand Total", // P
"Catatan", // Q
} }
for i, header := range headers { for i, header := range headers {
@@ -148,7 +152,7 @@ func setMarketingExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
return file.SetCellStyle(sheet, "A1", "P1", headerStyle) return file.SetCellStyle(sheet, "A1", "Q1", headerStyle)
} }
func setMarketingExportRows(file *excelize.File, sheet string, items []dto.MarketingListDTO) error { func setMarketingExportRows(file *excelize.File, sheet string, items []dto.MarketingListDTO) error {
@@ -162,17 +166,161 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
soDate := formatMarketingExportDate(item.SoDate) soDate := formatMarketingExportDate(item.SoDate)
status := formatMarketingExportStatus(item) status := formatMarketingExportStatus(item)
customer := safeMarketingExportText(item.Customer.Name) customer := safeMarketingExportText(item.Customer.Name)
grandTotal := sumMarketingGrandTotal(item.SalesOrder)
notes := safeMarketingExportText(item.Notes) notes := safeMarketingExportText(item.Notes)
salesPerson := safeMarketingExportText(item.SalesPerson.Name)
isDeliveryOrder := strings.EqualFold(strings.TrimSpace(status), "delivery order")
// ── Delivery Order branch ──────────────────────────────────────────────
if isDeliveryOrder {
grandTotal := sumDeliveryGrandTotal(item.DeliveryOrder)
if len(item.DeliveryOrder) == 0 {
row++
r := strconv.Itoa(row)
vals := map[string]interface{}{
"A": soNumber, "B": soDate, "C": status, "D": customer, "E": salesPerson,
"F": "-", "G": "-", "H": "-", "I": "-", "J": "-", "K": "-",
"L": "-", "M": "-", "N": "-", "O": "-",
"P": grandTotal, "Q": notes,
}
for col, val := range vals {
if err := file.SetCellValue(sheet, col+r, val); err != nil {
return err
}
}
continue
}
// Build lookup map: MarketingProductId → SO product (for Week & MarketingType)
soProductMap := make(map[uint]*dto.DeliveryMarketingProductDTO, len(item.SalesOrder))
for i := range item.SalesOrder {
soProductMap[item.SalesOrder[i].Id] = &item.SalesOrder[i]
}
for _, group := range item.DeliveryOrder {
doNumber := safeMarketingExportText(group.DoNumber)
doDate := "-"
if group.DeliveryDate != nil {
doDate = formatMarketingExportDate(*group.DeliveryDate)
}
gudang := "-"
if group.Warehouse != nil {
gudang = safeMarketingExportText(group.Warehouse.Name)
}
if len(group.Deliveries) == 0 {
row++
r := strconv.Itoa(row)
vals := map[string]interface{}{
"A": doNumber, "B": doDate, "C": status, "D": customer, "E": salesPerson,
"F": "-", "G": "-", "H": gudang, "I": "-", "J": "-", "K": "-",
"L": "-", "M": "-", "N": "-", "O": "-",
"P": grandTotal, "Q": notes,
}
for col, val := range vals {
if err := file.SetCellValue(sheet, col+r, val); err != nil {
return err
}
}
continue
}
for _, delivery := range group.Deliveries {
row++
r := strconv.Itoa(row)
productName := "-"
if delivery.ProductWarehouse != nil && delivery.ProductWarehouse.Product != nil {
if n := strings.TrimSpace(delivery.ProductWarehouse.Product.Name); n != "" {
productName = n
}
}
week := "-"
marketingType := "-"
if soProduct, ok := soProductMap[delivery.MarketingProductId]; ok {
if soProduct.Week != nil {
week = strconv.Itoa(*soProduct.Week)
}
marketingType = safeMarketingExportText(soProduct.MarketingType)
}
if err := file.SetCellValue(sheet, "A"+r, doNumber); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+r, doDate); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+r, status); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+r, customer); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+r, salesPerson); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+r, marketingType); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+r, productName); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+r, gudang); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+r, week); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+r, delivery.Qty); err != nil {
return err
}
if delivery.TotalPeti != nil {
if err := file.SetCellValue(sheet, "K"+r, *delivery.TotalPeti); err != nil {
return err
}
} else {
if err := file.SetCellValue(sheet, "K"+r, "-"); err != nil {
return err
}
}
if err := file.SetCellValue(sheet, "L"+r, delivery.AvgWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+r, delivery.TotalWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+r, delivery.UnitPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+r, delivery.TotalPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+r, grandTotal); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+r, notes); err != nil {
return err
}
}
}
continue
}
// ── Sales Order branch (all other statuses) ───────────────────────────
grandTotal := sumMarketingGrandTotal(item.SalesOrder)
if len(item.SalesOrder) == 0 { if len(item.SalesOrder) == 0 {
row++ row++
r := strconv.Itoa(row) r := strconv.Itoa(row)
vals := map[string]interface{}{ vals := map[string]interface{}{
"A": soNumber, "B": soDate, "C": status, "D": customer, "A": soNumber, "B": soDate, "C": status, "D": customer, "E": salesPerson,
"E": "-", "F": "-", "G": "-", "H": "-", "I": "-", "J": "-", "F": "-", "G": "-", "H": "-", "I": "-", "J": "-", "K": "-",
"K": "-", "L": "-", "M": "-", "N": "-", "L": "-", "M": "-", "N": "-", "O": "-",
"O": grandTotal, "P": notes, "P": grandTotal, "Q": notes,
} }
for col, val := range vals { for col, val := range vals {
if err := file.SetCellValue(sheet, col+r, val); err != nil { if err := file.SetCellValue(sheet, col+r, val); err != nil {
@@ -198,9 +346,9 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
week = strconv.Itoa(*prod.Week) week = strconv.Itoa(*prod.Week)
} }
satuan := "-" gudang := "-"
if prod.ConvertionUnit != nil && strings.TrimSpace(*prod.ConvertionUnit) != "" { if prod.ProductWarehouse != nil {
satuan = *prod.ConvertionUnit gudang = safeMarketingExportText(prod.ProductWarehouse.Warehouse.Name)
} }
if err := file.SetCellValue(sheet, "A"+r, soNumber); err != nil { if err := file.SetCellValue(sheet, "A"+r, soNumber); err != nil {
@@ -215,46 +363,49 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err := file.SetCellValue(sheet, "D"+r, customer); err != nil { if err := file.SetCellValue(sheet, "D"+r, customer); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "E"+r, safeMarketingExportText(prod.MarketingType)); err != nil { if err := file.SetCellValue(sheet, "E"+r, salesPerson); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "F"+r, productName); err != nil { if err := file.SetCellValue(sheet, "F"+r, safeMarketingExportText(prod.MarketingType)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "G"+r, week); err != nil { if err := file.SetCellValue(sheet, "G"+r, productName); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "H"+r, prod.Qty); err != nil { if err := file.SetCellValue(sheet, "H"+r, gudang); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "I"+r, satuan); err != nil { if err := file.SetCellValue(sheet, "I"+r, week); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+r, prod.Qty); err != nil {
return err return err
} }
if prod.TotalPeti != nil { if prod.TotalPeti != nil {
if err := file.SetCellValue(sheet, "J"+r, *prod.TotalPeti); err != nil { if err := file.SetCellValue(sheet, "K"+r, *prod.TotalPeti); err != nil {
return err return err
} }
} else { } else {
if err := file.SetCellValue(sheet, "J"+r, "-"); err != nil { if err := file.SetCellValue(sheet, "K"+r, "-"); err != nil {
return err return err
} }
} }
if err := file.SetCellValue(sheet, "K"+r, prod.AvgWeight); err != nil { if err := file.SetCellValue(sheet, "L"+r, prod.AvgWeight); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "L"+r, prod.TotalWeight); err != nil { if err := file.SetCellValue(sheet, "M"+r, prod.TotalWeight); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "M"+r, prod.UnitPrice); err != nil { if err := file.SetCellValue(sheet, "N"+r, prod.UnitPrice); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "N"+r, prod.TotalPrice); err != nil { if err := file.SetCellValue(sheet, "O"+r, prod.TotalPrice); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "O"+r, grandTotal); err != nil { if err := file.SetCellValue(sheet, "P"+r, grandTotal); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "P"+r, notes); err != nil { if err := file.SetCellValue(sheet, "Q"+r, notes); err != nil {
return err return err
} }
} }
@@ -276,7 +427,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A2", "P"+lastRowStr, dataStyle); err != nil { if err := file.SetCellStyle(sheet, "A2", "Q"+lastRowStr, dataStyle); err != nil {
return err return err
} }
@@ -287,7 +438,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "K2", "O"+lastRowStr, numberStyle); err != nil { if err := file.SetCellStyle(sheet, "L2", "P"+lastRowStr, numberStyle); err != nil {
return err return err
} }
@@ -298,7 +449,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err != nil { if err != nil {
return err return err
} }
for _, col := range []string{"G", "H", "J"} { for _, col := range []string{"I", "J", "K"} {
if err := file.SetCellStyle(sheet, col+"2", col+lastRowStr, centerStyle); err != nil { if err := file.SetCellStyle(sheet, col+"2", col+lastRowStr, centerStyle); err != nil {
return err return err
} }
@@ -327,16 +478,23 @@ func formatMarketingExportStatus(item dto.MarketingListDTO) string {
return safeMarketingExportText(item.LatestApproval.StepName) return safeMarketingExportText(item.LatestApproval.StepName)
} }
func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 { func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 {
total := 0.0 total := 0.0
for _, item := range items { for _, item := range items {
total += item.TotalPrice total += item.TotalPrice
} }
return total return total
} }
func sumDeliveryGrandTotal(groups []dto.DeliveryGroupDTO) float64 {
total := 0.0
for _, g := range groups {
for _, d := range g.Deliveries {
total += d.TotalPrice
}
}
return total
}
func safeMarketingExportText(value string) string { func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
@@ -28,6 +28,8 @@ type MarketingListDTO struct {
Customer customerDTO.CustomerRelationDTO `json:"customer"` Customer customerDTO.CustomerRelationDTO `json:"customer"`
SalesPerson userDTO.UserRelationDTO `json:"sales_person"` SalesPerson userDTO.UserRelationDTO `json:"sales_person"`
SoDocs string `json:"so_docs"` SoDocs string `json:"so_docs"`
GrandTotalSO float64 `json:"grand_total_so"`
GrandTotalDO float64 `json:"grand_total_do"`
SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"` SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"`
DeliveryOrder []DeliveryGroupDTO `json:"delivery_order"` DeliveryOrder []DeliveryGroupDTO `json:"delivery_order"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedUser userDTO.UserRelationDTO `json:"created_user"`
@@ -198,11 +200,18 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType) salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType)
} }
} }
var grandTotalSO float64
for _, p := range marketing.Products {
grandTotalSO += p.TotalPrice
}
return MarketingListDTO{ return MarketingListDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing), MarketingRelationDTO: ToMarketingRelationDTO(marketing),
Customer: customer, Customer: customer,
SalesPerson: salesPerson, SalesPerson: salesPerson,
SoDocs: marketing.SoDocs, SoDocs: marketing.SoDocs,
GrandTotalSO: grandTotalSO,
GrandTotalDO: marketing.GrandTotal,
SalesOrder: salesOrderProducts, SalesOrder: salesOrderProducts,
DeliveryOrder: extractDeliveryGroupsFromProducts(marketing), DeliveryOrder: extractDeliveryGroupsFromProducts(marketing),
CreatedUser: createdUser, CreatedUser: createdUser,
@@ -287,6 +287,16 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
db = db.Where("marketings.customer_id = ?", params.CustomerId) db = db.Where("marketings.customer_id = ?", params.CustomerId)
} }
if params.WarehouseID != 0 {
db = db.Where(`EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
WHERE mp.marketing_id = marketings.id
AND pw.warehouse_id = ?
)`, params.WarehouseID)
}
db = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID) db = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID)
db = s.applyMarketingSearchFilter(c.Context(), db, params.Search) db = s.applyMarketingSearchFilter(c.Context(), db, params.Search)
@@ -31,6 +31,7 @@ type DeliveryOrderQuery struct {
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_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"`
} }
@@ -172,6 +172,23 @@ func (u *ChickinController) DeleteOne(c *fiber.Ctx) error {
}) })
} }
func (u *ChickinController) UpdateChickInDate(c *fiber.Ctx) error {
req := new(validation.UpdateChickInDate)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if err := u.ChickinService.UpdateChickInDate(c, req); err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Chick in date berhasil diperbarui",
})
}
func (u *ChickinController) Approval(c *fiber.Ctx) error { func (u *ChickinController) Approval(c *fiber.Ctx) error {
req := new(validation.Approve) req := new(validation.Approve)
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -18,6 +19,7 @@ type ProjectChickinRepository interface {
GetTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) GetTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error)
GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error)
UpdateUsageFields(ctx context.Context, tx *gorm.DB, chickinID uint, usageQty, pendingUsageQty float64) error UpdateUsageFields(ctx context.Context, tx *gorm.DB, chickinID uint, usageQty, pendingUsageQty float64) error
UpdateChickInDateByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, pfkID uint, newDate time.Time) error
} }
type ChickinRepositoryImpl struct { type ChickinRepositoryImpl struct {
@@ -134,3 +136,10 @@ func (r *ChickinRepositoryImpl) UpdateUsageFields(ctx context.Context, tx *gorm.
"pending_usage_qty": pendingUsageQty, "pending_usage_qty": pendingUsageQty,
}).Error }).Error
} }
func (r *ChickinRepositoryImpl) UpdateChickInDateByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, pfkID uint, newDate time.Time) error {
return tx.WithContext(ctx).
Model(&entity.ProjectChickin{}).
Where("project_flock_kandang_id = ? AND deleted_at IS NULL", pfkID).
Update("chick_in_date", newDate).Error
}
@@ -17,6 +17,7 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService
route.Get("/", m.RequirePermissions(m.P_ChickinsGetAll), ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_ChickinsGetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne)
route.Patch("/chick-in-date", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.UpdateChickInDate)
route.Get("/:id", m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne)
// route.Patch("/:id", ctrl.UpdateOne) // route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", ctrl.DeleteOne)
@@ -48,6 +48,7 @@ type ChickinService interface {
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error)
EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error
UpdateChickInDate(ctx *fiber.Ctx, req *validation.UpdateChickInDate) error
} }
type chickinService struct { type chickinService struct {
@@ -2110,3 +2111,38 @@ func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKan
return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording")
} }
func (s chickinService) UpdateChickInDate(ctx *fiber.Ctx, req *validation.UpdateChickInDate) error {
if err := s.Validate.Struct(req); err != nil {
return err
}
newDate, err := time.Parse("2006-01-02", req.ChickInDate)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Format tanggal tidak valid, gunakan YYYY-MM-DD")
}
_, err = s.ProjectflockKandangRepo.GetByID(ctx.Context(), req.ProjectFlockKandangId)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, "Project flock kandang tidak ditemukan")
}
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 {
return err
}
return tx.Exec(`
UPDATE recordings
SET day = GREATEST(0, (record_datetime::date - ?::date)::int),
updated_at = NOW()
WHERE project_flock_kandangs_id = ?
AND deleted_at IS NULL
`, req.ChickInDate, req.ProjectFlockKandangId).Error
}); err != nil {
return err
}
s.invalidateDepreciationSnapshots(ctx.Context(), nil, []uint{req.ProjectFlockKandangId}, newDate)
return nil
}
@@ -27,3 +27,8 @@ type Approve struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
} }
type UpdateChickInDate struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,gt=0"`
ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"`
}
@@ -8,10 +8,12 @@ import (
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response" "gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -75,6 +77,43 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
} }
listDTO := dto.ToRecordingListDTOs(result) listDTO := dto.ToRecordingListDTOs(result)
recordingIDs := make([]uint, 0, len(result))
for i := range result {
if result[i].Id != 0 {
recordingIDs = append(recordingIDs, result[i].Id)
}
}
if len(recordingIDs) > 0 {
eggs, err := u.RecordingService.GetEggsWithFlagsByRecordingIDs(c.Context(), recordingIDs)
if err != nil {
return err
}
eggByRecording := make(map[uint][]entity.RecordingEgg, len(recordingIDs))
for _, egg := range eggs {
eggByRecording[egg.RecordingId] = append(eggByRecording[egg.RecordingId], egg)
}
for i := range listDTO {
id := listDTO[i].Id
if eggList, ok := eggByRecording[id]; ok {
breakdown := make(map[string]dto.EggExportBreakdownDTO)
for _, egg := range eggList {
flagName := eggTypeFromProductName(egg.ProductWarehouse.Product.Name)
if flagName == "" {
continue
}
entry := breakdown[flagName]
entry.Qty += egg.Qty
if egg.Weight != nil {
entry.Kg += *egg.Weight
}
breakdown[flagName] = entry
}
listDTO[i].EggExportBreakdown = breakdown
}
}
}
if strings.EqualFold(exportType, "excel") { if strings.EqualFold(exportType, "excel") {
return exportRecordingListExcel(c, listDTO) return exportRecordingListExcel(c, listDTO)
} }
@@ -94,6 +133,33 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
}) })
} }
// eggTypeFromProductName maps product name to egg type flag name by keyword matching.
// Falls back to empty string if no keyword matches.
func eggTypeFromProductName(name string) string {
normalized := strings.ToLower(strings.TrimSpace(name))
if normalized == "" {
return ""
}
// Ordered longest-first to prefer "papacal" over partial match of "pacal", etc.
keywords := []struct {
keyword string
flag string
}{
{"papacal", string(utils.FlagTelurPapacal)},
{"jumbo", string(utils.FlagTelurJumbo)},
{"retak", string(utils.FlagTelurRetak)},
{"putih", string(utils.FlagTelurPutih)},
{"pecah", string(utils.FlagTelurPecah)},
{"utuh", string(utils.FlagTelurUtuh)},
}
for _, k := range keywords {
if strings.Contains(normalized, k.keyword) {
return k.flag
}
}
return ""
}
func (u *RecordingController) GetOne(c *fiber.Ctx) error { func (u *RecordingController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -8,6 +8,7 @@ import (
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
@@ -79,6 +80,18 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error {
"AB": 18, "AB": 18,
"AC": 24, "AC": 24,
"AD": 18, "AD": 18,
"AE": 12,
"AF": 10,
"AG": 12,
"AH": 10,
"AI": 12,
"AJ": 10,
"AK": 12,
"AL": 10,
"AM": 12,
"AN": 10,
"AO": 12,
"AP": 10,
} }
for col, width := range columnWidths { for col, width := range columnWidths {
@@ -208,6 +221,31 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
eggTypes := []struct {
col1, col2, label string
}{
{"AE", "AF", "Telur Utuh"},
{"AG", "AH", "Telur Pecah"},
{"AI", "AJ", "Telur Putih"},
{"AK", "AL", "Telur Retak"},
{"AM", "AN", "Telur Papacal"},
{"AO", "AP", "Telur Jumbo"},
}
for _, et := range eggTypes {
if err := file.MergeCell(sheet, et.col1+"1", et.col2+"1"); err != nil {
return err
}
if err := file.SetCellValue(sheet, et.col1+"1", et.label); err != nil {
return err
}
if err := file.SetCellValue(sheet, et.col1+"2", "Butir"); err != nil {
return err
}
if err := file.SetCellValue(sheet, et.col2+"2", "Kg"); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{ headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{ Font: &excelize.Font{
Bold: true, Bold: true,
@@ -234,7 +272,7 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
return file.SetCellStyle(sheet, "A1", "AD2", headerStyle) return file.SetCellStyle(sheet, "A1", "AP2", headerStyle)
} }
func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error { func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error {
@@ -245,7 +283,8 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
columns := []string{ columns := []string{
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
"AC", "AD", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN",
"AO", "AP",
} }
currentRow := 3 currentRow := 3
@@ -293,14 +332,14 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
// Expand recordings into one row per sapronak // Expand recordings into one row per sapronak
type sapronakRow struct { type sapronakRow struct {
name string name string
input string input interface{} // float64 for numeric, string "-" for placeholder
} }
sapronaks := make([]sapronakRow, 0) sapronaks := make([]sapronakRow, 0)
if len(item.FeedUsage) > 0 { if len(item.FeedUsage) > 0 {
for _, fu := range item.FeedUsage { for _, fu := range item.FeedUsage {
sapronaks = append(sapronaks, sapronakRow{ sapronaks = append(sapronaks, sapronakRow{
name: safeExportText(fu.ProductName), name: safeExportText(fu.ProductName),
input: formatNumberID(fu.UsageAmount+fu.PendingQty, 2, true), input: fu.UsageAmount + fu.PendingQty,
}) })
} }
} else { } else {
@@ -311,6 +350,23 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
for sIdx, s := range sapronaks { for sIdx, s := range sapronaks {
if sIdx == 0 { if sIdx == 0 {
eggQty := func(flagName string) int {
if item.EggExportBreakdown != nil {
if bd, ok := item.EggExportBreakdown[flagName]; ok {
return bd.Qty
}
}
return 0
}
eggKg := func(flagName string) float64 {
if item.EggExportBreakdown != nil {
if bd, ok := item.EggExportBreakdown[flagName]; ok {
return bd.Kg
}
}
return 0
}
rowValues := []interface{}{ rowValues := []interface{}{
i + 1, // A i + 1, // A
locationName, // B locationName, // B
@@ -320,28 +376,40 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F
formatAgeLabel(item), // G formatAgeLabel(item), // G
formatDateIndonesian(item.RecordDatetime), // H formatDateIndonesian(item.RecordDatetime), // H
formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), // I item.ProjectFlock.TotalChickQty, // I
formatNumberID(item.FcrValue, 2, true), // J item.FcrValue, // J
formatNumberID(fcrStd, 2, true), // K fcrStd, // K
formatNumberID(item.FeedIntake, 2, true), // L item.FeedIntake, // L
formatNumberID(feedIntakeStd, 2, true), // M feedIntakeStd, // M
formatPercentID(item.CumDepletionRate, 2), // N item.CumDepletionRate, // N
formatPercentID(maxDepletionStd, 2), // O maxDepletionStd, // O
formatNumberID(item.TotalDepletionQty, 2, true), // P item.TotalDepletionQty, // P
formatNumberID(item.EggMass, 2, true), // Q item.EggMass, // Q
formatNumberID(eggMassStd, 2, true), // R eggMassStd, // R
formatNumberID(item.EggWeight, 2, true), // S item.EggWeight, // S
formatNumberID(eggWeightStd, 2, true), // T eggWeightStd, // T
formatPercentID(item.HenDay, 2), // U item.HenDay, // U
formatPercentID(henDayStd, 2), // V henDayStd, // V
formatPercentID(item.HenHouse, 2), // W item.HenHouse, // W
formatPercentID(henHouseStd, 2), // X henHouseStd, // X
formatApprovalStatus(item), // Y formatApprovalStatus(item), // Y
safeExportText(pointerString(item.Approval.Notes)), // Z safeExportText(pointerString(item.Approval.Notes)), // Z
createdBy, // AA createdBy, // AA
formatDateIndonesian(item.CreatedAt), // AB formatDateIndonesian(item.CreatedAt), // AB
s.name, // AC s.name, // AC
s.input, // AD s.input, // AD
eggQty(string(utils.FlagTelurUtuh)), // AE
eggKg(string(utils.FlagTelurUtuh)), // AF
eggQty(string(utils.FlagTelurPecah)), // AG
eggKg(string(utils.FlagTelurPecah)), // AH
eggQty(string(utils.FlagTelurPutih)), // AI
eggKg(string(utils.FlagTelurPutih)), // AJ
eggQty(string(utils.FlagTelurRetak)), // AK
eggKg(string(utils.FlagTelurRetak)), // AL
eggQty(string(utils.FlagTelurPapacal)), // AM
eggKg(string(utils.FlagTelurPapacal)), // AN
eggQty(string(utils.FlagTelurJumbo)), // AO
eggKg(string(utils.FlagTelurJumbo)), // AP
} }
for idx, col := range columns { for idx, col := range columns {
@@ -379,7 +447,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AD%d", lastRow), dataCenterStyle); err != nil { if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AP%d", lastRow), dataCenterStyle); err != nil {
return err return err
} }
@@ -445,6 +513,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
mergeCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", mergeCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
"AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP",
} }
for _, rng := range itemRanges { for _, rng := range itemRanges {
if rng.end > rng.start { if rng.end > rng.start {
@@ -454,6 +523,53 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
} }
file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle) file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle)
file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle) file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle)
// Egg columns use center + thick bottom border
for _, col := range []string{"AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP"} {
file.SetCellStyle(sheet, fmt.Sprintf("%s%d", col, rng.end), fmt.Sprintf("%s%d", col, rng.end), borderBottomCenterStyle)
}
}
numFmtInt := "0"
numberIntStyle, err := file.NewStyle(&excelize.Style{
CustomNumFmt: &numFmtInt,
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "E6E6E6", Style: 1},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
numFmtFloat := "0.00"
numberFloatStyle, err := file.NewStyle(&excelize.Style{
CustomNumFmt: &numFmtFloat,
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "E6E6E6", Style: 1},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
intCols := []string{"E", "I", "AE", "AG", "AI", "AK", "AM", "AO"}
for _, col := range intCols {
if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), numberIntStyle); err != nil {
return err
}
}
floatCols := []string{"J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "AD", "AF", "AH", "AJ", "AL", "AN", "AP"}
for _, col := range floatCols {
if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), numberFloatStyle); err != nil {
return err
}
} }
return nil return nil
@@ -100,6 +100,11 @@ type RecordingFeedUsageDTO struct {
PendingQty float64 `json:"pending_qty"` PendingQty float64 `json:"pending_qty"`
} }
type EggExportBreakdownDTO struct {
Qty int `json:"qty"`
Kg float64 `json:"kg"`
}
type RecordingListDTO struct { type RecordingListDTO struct {
RecordingRelationDTO RecordingRelationDTO
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
@@ -108,6 +113,7 @@ type RecordingListDTO struct {
Kandang *RecordingKandangDTO `json:"kandang,omitempty"` Kandang *RecordingKandangDTO `json:"kandang,omitempty"`
Location *RecordingLocationDTO `json:"location,omitempty"` Location *RecordingLocationDTO `json:"location,omitempty"`
FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"` FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"`
EggExportBreakdown map[string]EggExportBreakdownDTO `json:"egg_breakdown,omitempty"`
} }
type RecordingDetailDTO struct { type RecordingDetailDTO struct {
@@ -51,6 +51,7 @@ type RecordingRepository interface {
UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error
UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error
GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error)
GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error)
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
@@ -581,6 +582,22 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID(
return &egg, nil return &egg, nil
} }
func (r *RecordingRepositoryImpl) GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) {
if len(recordingIDs) == 0 {
return nil, nil
}
var eggs []entity.RecordingEgg
err := r.DB().WithContext(ctx).
Preload("ProductWarehouse.Product").
Where("recording_eggs.recording_id IN ?", recordingIDs).
Find(&eggs).Error
if err != nil {
return nil, err
}
return eggs, nil
}
func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) { func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) {
if projectFlockKandangId == 0 { if projectFlockKandangId == 0 {
return false, nil return false, nil
@@ -46,6 +46,7 @@ type RecordingService interface {
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error)
} }
type recordingService struct { type recordingService struct {
@@ -259,6 +260,10 @@ func (s recordingService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Qu
return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict) return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict)
} }
func (s recordingService) GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) {
return s.Repository.GetEggsWithFlagsByRecordingIDs(ctx, recordingIDs)
}
func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) {
if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil {
return nil, err return nil, err