424 lines
15 KiB
Go
424 lines
15 KiB
Go
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.CurrentStock, &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.CurrentStock, &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.CurrentStock, &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: current_stock - 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: current_stock 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 currentStock decimal.Decimal
|
|
if err := database.DB.QueryRow(`SELECT COALESCE(SUM(on_hand), 0) FROM inventory_balances WHERE material_id = ?`, mid).Scan(¤tStock); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
initialBalances[mid] = currentStock.Sub(decimal.NewFromInt(int64(delta)))
|
|
}
|
|
|
|
// Process from OLDEST to NEWEST within the page (reverse order of our DESC result).
|
|
// balance_for_oldest = current_stock - 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()
|
|
}
|