package repository import ( "database/sql" "fmt" "github.com/shopspring/decimal" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) type StockRepo struct{} func NewStockRepo() *StockRepo { return &StockRepo{} } func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) { countQuery := `SELECT COUNT(*) FROM materials m WHERE 1=1` var countArgs []interface{} if search != "" { countQuery += ` AND (m.material_code LIKE ? OR m.name LIKE ?)` searchPattern := "%" + search + "%" countArgs = append(countArgs, searchPattern, searchPattern) } var total int64 err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total) if err != nil { return nil, 0, err } query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit, COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), IFNULL(pc.code, ''), IFNULL(c.code, '') FROM materials m LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE LEFT JOIN material_categories c ON c.id = m.category_id LEFT JOIN material_categories pc ON pc.id = c.parent_id WHERE 1=1` var args []interface{} if search != "" { query += ` AND (m.material_code LIKE ? OR m.name LIKE ?)` searchPattern := "%" + search + "%" args = append(args, searchPattern, searchPattern) } query += ` GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code ORDER BY m.id LIMIT ? OFFSET ?` args = append(args, limit, offset) rows, err := database.DB.Query(query, args...) if err != nil { return nil, 0, err } defer rows.Close() var stocks []model.Stock for rows.Next() { var s model.Stock err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.MajorCategory, &s.MinorCategory) if err != nil { return nil, 0, err } stocks = append(stocks, s) } return stocks, total, nil } func (r *StockRepo) GetByMaterialID(materialID int64) (*model.Stock, error) { s := &model.Stock{} query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit, COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), IFNULL(pc.code, ''), IFNULL(c.code, '') FROM materials m LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE LEFT JOIN material_categories c ON c.id = m.category_id LEFT JOIN material_categories pc ON pc.id = c.parent_id WHERE m.id = ? GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code` err := database.DB.QueryRow(query, materialID).Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.MajorCategory, &s.MinorCategory) if err != nil { return nil, err } return s, nil } func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, error) { countQuery := `SELECT COUNT(*) FROM ( SELECT m.id FROM materials m LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE GROUP BY m.id HAVING COALESCE(SUM(ib.on_hand), 0) < COALESCE(MAX(swr.minimum_available), 0) ) warning_materials` var total int64 err := database.DB.QueryRow(countQuery).Scan(&total) if err != nil { return nil, 0, err } query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit, COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), IFNULL(pc.code, ''), IFNULL(c.code, '') FROM materials m LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE LEFT JOIN material_categories c ON c.id = m.category_id LEFT JOIN material_categories pc ON pc.id = c.parent_id GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code HAVING COALESCE(SUM(ib.on_hand), 0) < COALESCE(MAX(swr.minimum_available), 0) ORDER BY m.id LIMIT ? OFFSET ?` rows, err := database.DB.Query(query, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() var stocks []model.Stock for rows.Next() { var s model.Stock err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.MajorCategory, &s.MinorCategory) if err != nil { return nil, 0, err } stocks = append(stocks, s) } return stocks, total, nil } func (r *StockRepo) CreateLog(log *model.StockLog) error { query := `INSERT INTO stock_logs (material_id, type, quantity, related_order_type, related_order_id) VALUES (?, ?, ?, ?, ?)` result, err := database.DB.Exec(query, log.MaterialID, log.Type, log.Quantity, log.RelatedOrderType, log.RelatedOrderID) if err != nil { return err } id, err := result.LastInsertId() if err != nil { return err } log.ID = id return nil } func (r *StockRepo) GetLogsByMaterialID(materialID int64, limit, offset int) ([]model.StockLog, int64, error) { countQuery := `SELECT COUNT(*) FROM stock_logs WHERE material_id = ?` var total int64 err := database.DB.QueryRow(countQuery, materialID).Scan(&total) if err != nil { return nil, 0, err } query := `SELECT id, material_id, type, quantity, related_order_type, related_order_id, created_at FROM stock_logs WHERE material_id = ? ORDER BY id LIMIT ? OFFSET ?` rows, err := database.DB.Query(query, materialID, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() var logs []model.StockLog for rows.Next() { var log model.StockLog err := rows.Scan(&log.ID, &log.MaterialID, &log.Type, &log.Quantity, &log.RelatedOrderType, &log.RelatedOrderID, &log.CreatedAt) if err != nil { return nil, 0, err } logs = append(logs, log) } return logs, total, nil } func (r *StockRepo) GetAllLogs(limit, offset int) ([]model.StockLog, int64, error) { countQuery := `SELECT COUNT(*) FROM stock_logs` var total int64 err := database.DB.QueryRow(countQuery).Scan(&total) if err != nil { return nil, 0, err } // Compute running_balance as cumulative sum of (in=+quantity, out=-quantity) ordered by id ASC, // then reverse to show newest first while preserving correct cumulative balance for each row query := ` SELECT id, material_id, type, quantity, related_order_type, related_order_id, created_at FROM ( SELECT *, (SELECT COALESCE(SUM(CASE WHEN type='in' THEN quantity ELSE -quantity END), 0) FROM stock_logs s2 WHERE s2.id <= stock_logs.id) as running_balance FROM stock_logs ) as t ORDER BY id DESC LIMIT ? OFFSET ?` rows, err := database.DB.Query(query, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() var logs []model.StockLog for rows.Next() { var log model.StockLog err := rows.Scan(&log.ID, &log.MaterialID, &log.Type, &log.Quantity, &log.RelatedOrderType, &log.RelatedOrderID, &log.CreatedAt) if err != nil { return nil, 0, err } logs = append(logs, log) } return logs, total, nil } // GetAllLogsWithBalance returns logs ordered newest-first with correct running balance. // Balance = stock level BEFORE this log's change was applied. // Strategy: Walk rows from oldest to newest within the page, accumulating balance. // The starting balance for the oldest row is: total on-hand - sum(all deltas for this material newer than the page). func (r *StockRepo) GetAllLogsWithBalance(limit, offset int) ([]model.StockLog, int64, error) { var total int64 if err := database.DB.QueryRow(`SELECT COUNT(*) FROM stock_logs`).Scan(&total); err != nil { return nil, 0, err } // Fetch page rows ordered newest-first (id DESC). rows, err := database.DB.Query(` SELECT id, material_id, type, quantity, related_order_type, related_order_id, created_at FROM stock_logs ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() type rowData struct { log model.StockLog delta int } var pageRows []rowData materialIDs := make(map[int64]bool) for rows.Next() { var r rowData if err := rows.Scan(&r.log.ID, &r.log.MaterialID, &r.log.Type, &r.log.Quantity, &r.log.RelatedOrderType, &r.log.RelatedOrderID, &r.log.CreatedAt); err != nil { return nil, 0, err } if r.log.Type == "in" { r.delta = r.log.Quantity } else { r.delta = -r.log.Quantity } pageRows = append(pageRows, r) materialIDs[r.log.MaterialID] = true } if len(pageRows) == 0 { return []model.StockLog{}, total, nil } // Get initialBalance: total on-hand of this material minus all deltas for this material. // This gives the balance BEFORE the oldest log in this page was applied. initialBalances := make(map[int64]decimal.Decimal) for mid := range materialIDs { var delta int database.DB.QueryRow(` SELECT COALESCE(SUM(CASE WHEN type='in' THEN quantity ELSE -quantity END), 0) FROM stock_logs WHERE material_id = ?`, mid).Scan(&delta) var totalOnHand decimal.Decimal if err := database.DB.QueryRow(`SELECT COALESCE(SUM(on_hand), 0) FROM inventory_balances WHERE material_id = ?`, mid).Scan(&totalOnHand); err != nil { return nil, 0, err } initialBalances[mid] = totalOnHand.Sub(decimal.NewFromInt(int64(delta))) } // Process from OLDEST to NEWEST within the page (reverse order of our DESC result). // balance_for_oldest = total_on_hand - newer_than_page_delta // balance_after_row = balance_before_row + delta for i := len(pageRows) - 1; i >= 0; i-- { r := &pageRows[i] bal := initialBalances[r.log.MaterialID] if r.log.Type == "in" { bal = bal.Add(decimal.NewFromInt(int64(r.log.Quantity))) } else { bal = bal.Sub(decimal.NewFromInt(int64(r.log.Quantity))) } r.log.RunningBalance = bal initialBalances[r.log.MaterialID] = bal } var logs []model.StockLog for _, r := range pageRows { logs = append(logs, r.log) } return logs, total, nil } // GetStock returns the current on-hand balance for a material across inventory dimensions. func (r *StockRepo) GetStock(materialID int64) (decimal.Decimal, error) { var stock decimal.Decimal err := database.DB.QueryRow(`SELECT COALESCE(SUM(on_hand), 0) FROM inventory_balances WHERE material_id = ?`, materialID).Scan(&stock) return stock, err } // GetStockForUpdateTx locks the legacy default location balance used by current inbound/outbound flows. func (r *StockRepo) GetStockForUpdateTx(tx *sql.Tx, materialID int64) (decimal.Decimal, error) { query := `SELECT ib.on_hand FROM inventory_balances ib JOIN organizations o ON o.id = ib.organization_id AND o.code = 'DEFAULT' JOIN warehouses w ON w.id = ib.warehouse_id AND w.code = 'DEFAULT' JOIN warehouse_locations l ON l.id = ib.location_id AND l.code = 'MAIN' WHERE ib.material_id = ? FOR UPDATE` var stock decimal.Decimal err := tx.QueryRow(query, materialID).Scan(&stock) if err == sql.ErrNoRows { return decimal.Zero, nil } return stock, err } // UpdateStockTx writes the legacy inbound/outbound change to the independent default inventory balance. func (r *StockRepo) UpdateStockTx(tx *sql.Tx, materialID int64, delta decimal.Decimal) error { query := `INSERT INTO inventory_balances (organization_id, warehouse_id, location_id, material_id, on_hand) SELECT o.id, w.id, l.id, ?, ? FROM organizations o JOIN warehouses w ON w.organization_id = o.id AND w.code = 'DEFAULT' JOIN warehouse_locations l ON l.warehouse_id = w.id AND l.code = 'MAIN' WHERE o.code = 'DEFAULT' ON DUPLICATE KEY UPDATE on_hand = on_hand + VALUES(on_hand), version = version + 1` result, err := tx.Exec(query, materialID, delta) if err != nil { return err } affected, err := result.RowsAffected() if err != nil { return err } if affected == 0 { return fmt.Errorf("default organization, warehouse, or location is missing") } return nil } // UpdateWarningStock stores the default warehouse threshold in the inventory domain. func (r *StockRepo) UpdateWarningStock(materialID int64, minimumAvailable decimal.Decimal) error { query := `INSERT INTO stock_warning_rules (organization_id, warehouse_id, material_id, minimum_available, enabled) SELECT o.id, w.id, ?, ?, TRUE FROM organizations o JOIN warehouses w ON w.organization_id = o.id AND w.code = 'DEFAULT' WHERE o.code = 'DEFAULT' ON DUPLICATE KEY UPDATE minimum_available = VALUES(minimum_available), enabled = TRUE` result, err := database.DB.Exec(query, materialID, minimumAvailable) if err != nil { return err } affected, err := result.RowsAffected() if err != nil { return err } if affected == 0 { return fmt.Errorf("default organization or warehouse is missing") } return nil } func (r *StockRepo) DeleteLogs(orderType string, orderID int64) error { query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?` _, err := database.DB.Exec(query, orderType, orderID) return err } // CreateLogTx creates a stock log within a transaction func (r *StockRepo) CreateLogTx(tx *sql.Tx, log *model.StockLog) error { query := `INSERT INTO stock_logs (material_id, type, quantity, related_order_type, related_order_id) VALUES (?, ?, ?, ?, ?)` result, err := tx.Exec(query, log.MaterialID, log.Type, log.Quantity, log.RelatedOrderType, log.RelatedOrderID) if err != nil { return err } id, err := result.LastInsertId() if err != nil { return err } log.ID = id return nil } // DeleteLogsTx deletes stock logs within a transaction func (r *StockRepo) DeleteLogsTx(tx *sql.Tx, orderType string, orderID int64) error { query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?` _, err := tx.Exec(query, orderType, orderID) return err } func (r *StockRepo) GetInventoryBalanceDetails(materialID int64) ([]model.InventoryBalanceDetail, error) { query := ` SELECT ib.id, ib.organization_id, o.name, ib.warehouse_id, w.name, ib.location_id, l.name, ib.material_id, ib.on_hand, ib.reserved, ib.locked, ib.in_transit, ib.updated_at FROM inventory_balances ib JOIN organizations o ON o.id = ib.organization_id JOIN warehouses w ON w.id = ib.warehouse_id JOIN warehouse_locations l ON l.id = ib.location_id WHERE ib.material_id = ? ORDER BY ib.id` rows, err := database.DB.Query(query, materialID) if err != nil { return nil, err } defer rows.Close() var details []model.InventoryBalanceDetail for rows.Next() { var d model.InventoryBalanceDetail if err := rows.Scan(&d.ID, &d.OrganizationID, &d.Organization, &d.WarehouseID, &d.Warehouse, &d.LocationID, &d.Location, &d.MaterialID, &d.OnHand, &d.Reserved, &d.Locked, &d.InTransit, &d.UpdatedAt); err != nil { return nil, err } details = append(details, d) } return details, rows.Err() }