muyu-apiserver/model/invproductmodel.go
kae 4759295ce1 fix: wrap GROUP_CONCAT with COALESCE to prevent NULL scan on products with no pans
FindProductSummary and FindColorDetail aggregate pan.position via GROUP_CONCAT
with a LEFT JOIN. When a product has no pans the join produces all-NULL rows,
causing GROUP_CONCAT to return NULL, which cannot be scanned into a Go string —
making the entire summary query fail silently and the frontend show an empty table.

Fix: wrap both GROUP_CONCAT(pan.position) calls with COALESCE(..., '').

Also removes CostPrice from the generated InvProduct model (field dropped from schema).

Adds integration tests TestFindProductSummaryNoPans and TestFindColorDetailNoPans
to guard against this class of NULL-scan regression.
2026-06-23 00:05:42 +08:00

220 lines
7.3 KiB
Go
Executable File

package model
import (
"context"
"fmt"
"strings"
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/core/stores/sqlx"
)
var _ InvProductModel = (*customInvProductModel)(nil)
// StockGroupResult is kept for GroupBy queries (color / location).
type StockGroupResult struct {
Name string `db:"name"`
Count int64 `db:"count"`
Quantity float64 `db:"quantity"`
}
type ProductSummary struct {
ProductName string `db:"product_name"`
Spec string `db:"spec"`
ColorCount int64 `db:"color_count"`
TotalPanCount int64 `db:"total_pan_count"`
TotalBoltCount int64 `db:"total_bolt_count"`
TotalLengthM float64 `db:"total_length_m"`
Colors string `db:"colors"`
Locations string `db:"locations"`
}
type ColorDetail struct {
ProductId string `db:"product_id"`
ProductName string `db:"product_name"`
Color string `db:"color"`
Spec string `db:"spec"`
ImageUrl string `db:"image_url"`
SalesPrice float64 `db:"sales_price"`
PanCount int64 `db:"pan_count"`
BoltCount int64 `db:"bolt_count"`
TotalLengthM float64 `db:"total_length_m"`
Locations string `db:"locations"`
}
type (
InvProductModel interface {
invProductModel
FindList(ctx context.Context, tenantId string, page, pageSize int64, productName, spec, color string, status int64) ([]*InvProduct, int64, error)
FindProductSummary(ctx context.Context, tenantId string) ([]*ProductSummary, error)
FindColorDetail(ctx context.Context, tenantId, productName string) ([]*ColorDetail, error)
FindGroupByColor(ctx context.Context, tenantId string) ([]StockGroupResult, error)
FindGroupByLocation(ctx context.Context, tenantId string) ([]StockGroupResult, error)
BulkInsert(ctx context.Context, products []*InvProduct) (int64, error)
}
customInvProductModel struct {
*defaultInvProductModel
}
)
func NewInvProductModel(conn sqlx.SqlConn, c cache.CacheConf, opts ...cache.Option) InvProductModel {
return &customInvProductModel{
defaultInvProductModel: newInvProductModel(conn, c, opts...),
}
}
const bulkBatchSize = 200
func (m *customInvProductModel) BulkInsert(ctx context.Context, products []*InvProduct) (int64, error) {
if len(products) == 0 {
return 0, nil
}
var totalAffected int64
for i := 0; i < len(products); i += bulkBatchSize {
end := i + bulkBatchSize
if end > len(products) {
end = len(products)
}
batch := products[i:end]
placeholder := "(" + strings.Repeat("?,", 9) + "?)"
placeholders := make([]string, len(batch))
args := make([]interface{}, 0, len(batch)*10)
for j, p := range batch {
placeholders[j] = placeholder
args = append(args,
p.ProductId, p.ProductName, p.ImageUrl, p.Spec, p.Color,
p.SalesPrice, p.Remark, p.Status, p.TenantId, p.DeletedAt,
)
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES %s",
m.table, invProductRowsExpectAutoSet, strings.Join(placeholders, ","))
result, err := m.ExecNoCacheCtx(ctx, query, args...)
if err != nil {
return totalAffected, fmt.Errorf("batch %d-%d: %w", i, end-1, err)
}
n, _ := result.RowsAffected()
totalAffected += n
}
return totalAffected, nil
}
func (m *customInvProductModel) FindList(ctx context.Context, tenantId string, page, pageSize int64, productName, spec, color string, status int64) ([]*InvProduct, int64, error) {
where := "WHERE deleted_at IS NULL AND tenant_id = ?"
args := []interface{}{tenantId}
if productName != "" {
where += " AND product_name LIKE ?"
args = append(args, "%"+productName+"%")
}
if spec != "" {
where += " AND spec LIKE ?"
args = append(args, "%"+spec+"%")
}
if color != "" {
where += " AND color = ?"
args = append(args, color)
}
if status >= 0 {
where += " AND status = ?"
args = append(args, status)
}
var total int64
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s %s", m.table, where)
err := m.QueryRowNoCacheCtx(ctx, &total, countQuery, args...)
if err != nil {
return nil, 0, err
}
var list []*InvProduct
query := fmt.Sprintf("SELECT %s FROM %s %s ORDER BY created_at DESC LIMIT ? OFFSET ?", invProductRows, m.table, where)
args = append(args, pageSize, (page-1)*pageSize)
err = m.QueryRowsNoCacheCtx(ctx, &list, query, args...)
if err != nil {
return nil, 0, err
}
return list, total, nil
}
func (m *customInvProductModel) FindProductSummary(ctx context.Context, tenantId string) ([]*ProductSummary, error) {
var list []*ProductSummary
query := `SELECT p.product_name, p.spec,
COUNT(DISTINCT p.product_id) AS color_count,
COUNT(DISTINCT pan.pan_id) AS total_pan_count,
COUNT(bolt.bolt_id) AS total_bolt_count,
COALESCE(SUM(bolt.length_m), 0) AS total_length_m,
COALESCE(GROUP_CONCAT(DISTINCT p.color), '') AS colors,
COALESCE(GROUP_CONCAT(DISTINCT pan.position ORDER BY pan.position), '') AS locations
FROM ` + m.table + ` p
LEFT JOIN ` + "`inv_product_pan`" + ` pan ON pan.product_id = p.product_id
LEFT JOIN ` + "`inv_product_bolt`" + ` bolt ON bolt.pan_id = pan.pan_id
WHERE p.tenant_id = ? AND p.deleted_at IS NULL
GROUP BY p.product_name, p.spec
ORDER BY p.product_name`
err := m.QueryRowsNoCacheCtx(ctx, &list, query, tenantId)
if err != nil {
return nil, err
}
return list, nil
}
func (m *customInvProductModel) FindGroupByColor(ctx context.Context, tenantId string) ([]StockGroupResult, error) {
var list []StockGroupResult
query := `SELECT p.color AS name, COUNT(DISTINCT p.product_id) AS count,
COALESCE(SUM(bolt.length_m), 0) AS quantity
FROM ` + m.table + ` p
LEFT JOIN ` + "`inv_product_pan`" + ` pan ON pan.product_id = p.product_id
LEFT JOIN ` + "`inv_product_bolt`" + ` bolt ON bolt.pan_id = pan.pan_id
WHERE p.tenant_id = ? AND p.deleted_at IS NULL
GROUP BY p.color ORDER BY count DESC`
err := m.QueryRowsNoCacheCtx(ctx, &list, query, tenantId)
if err != nil {
return nil, err
}
return list, nil
}
func (m *customInvProductModel) FindGroupByLocation(ctx context.Context, tenantId string) ([]StockGroupResult, error) {
var list []StockGroupResult
query := `SELECT pan.position AS name, COUNT(DISTINCT p.product_id) AS count,
COALESCE(SUM(bolt.length_m), 0) AS quantity
FROM ` + m.table + ` p
JOIN ` + "`inv_product_pan`" + ` pan ON pan.product_id = p.product_id
LEFT JOIN ` + "`inv_product_bolt`" + ` bolt ON bolt.pan_id = pan.pan_id
WHERE p.tenant_id = ? AND p.deleted_at IS NULL AND pan.position != ''
GROUP BY pan.position ORDER BY count DESC`
err := m.QueryRowsNoCacheCtx(ctx, &list, query, tenantId)
if err != nil {
return nil, err
}
return list, nil
}
func (m *customInvProductModel) FindColorDetail(ctx context.Context, tenantId, productName string) ([]*ColorDetail, error) {
var list []*ColorDetail
query := `SELECT p.product_id, p.product_name, p.color, p.spec, p.image_url, p.sales_price,
COUNT(DISTINCT pan.pan_id) AS pan_count,
COUNT(bolt.bolt_id) AS bolt_count,
COALESCE(SUM(bolt.length_m), 0) AS total_length_m,
COALESCE(GROUP_CONCAT(DISTINCT pan.position ORDER BY pan.position), '') AS locations
FROM ` + m.table + ` p
LEFT JOIN ` + "`inv_product_pan`" + ` pan ON pan.product_id = p.product_id
LEFT JOIN ` + "`inv_product_bolt`" + ` bolt ON bolt.pan_id = pan.pan_id
WHERE p.tenant_id = ? AND p.deleted_at IS NULL AND p.product_name = ?
GROUP BY p.product_id
ORDER BY p.color`
err := m.QueryRowsNoCacheCtx(ctx, &list, query, tenantId, productName)
if err != nil {
return nil, err
}
return list, nil
}