* feat: support product yarn batches * feat: improve product yarn ratio workflow * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
452 lines
12 KiB
Go
452 lines
12 KiB
Go
package model
|
|
|
|
// Integration tests for InvProduct model against the real dev database.
|
|
// These tests guard against model/schema drift (e.g. missing or extra columns).
|
|
//
|
|
// Run with the dev DB reachable:
|
|
// go test ./model/ -run TestInvProduct -v
|
|
// Skip in CI where DB is unavailable:
|
|
// go test ./model/ -short
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/zeromicro/go-zero/core/stores/cache"
|
|
"github.com/zeromicro/go-zero/core/stores/redis"
|
|
"github.com/zeromicro/go-zero/core/stores/sqlx"
|
|
)
|
|
|
|
const testDSN = "root:muyu2026@tcp(127.0.0.1:13306)/muyu_wms?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai"
|
|
|
|
func newTestProductModel(t *testing.T) InvProductModel {
|
|
t.Helper()
|
|
conn := sqlx.NewMysql(testDSN)
|
|
cacheConf := cache.CacheConf{{RedisConf: redis.RedisConf{Host: "127.0.0.1:6379", Type: "node"}, Weight: 100}}
|
|
return NewInvProductModel(conn, cacheConf)
|
|
}
|
|
|
|
func uniqueID(prefix string) string {
|
|
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
|
}
|
|
|
|
// TestInvProductInsert verifies that Insert succeeds against the live DB schema.
|
|
// This catches model/schema drift like the cost_price column removal.
|
|
func TestInvProductInsert(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
m := newTestProductModel(t)
|
|
ctx := context.Background()
|
|
|
|
p := &InvProduct{
|
|
ProductId: uniqueID("pid"),
|
|
ProductName: uniqueID("test_product"),
|
|
ImageUrl: "",
|
|
Spec: "100cm",
|
|
Color: "red",
|
|
ColorNo: "01",
|
|
ProductCode: uniqueID("TPC"),
|
|
SalesPrice: 99.9,
|
|
Remark: sql.NullString{String: "test remark", Valid: true},
|
|
Status: 1,
|
|
TenantId: "t_default_001",
|
|
}
|
|
|
|
result, err := m.Insert(ctx, p)
|
|
if err != nil {
|
|
t.Fatalf("Insert failed: %v", err)
|
|
}
|
|
id, _ := result.LastInsertId()
|
|
if id == 0 {
|
|
t.Fatal("expected non-zero LastInsertId")
|
|
}
|
|
t.Logf("inserted product id=%d", id)
|
|
|
|
// cleanup
|
|
t.Cleanup(func() { _ = m.Delete(ctx, id) })
|
|
}
|
|
|
|
// TestInvProductFindOne verifies Insert + FindOne round-trip.
|
|
func TestInvProductFindOne(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
m := newTestProductModel(t)
|
|
ctx := context.Background()
|
|
|
|
p := &InvProduct{
|
|
ProductId: uniqueID("pid"),
|
|
ProductName: uniqueID("test_product"),
|
|
Spec: "200cm",
|
|
Color: "blue",
|
|
ColorNo: "01",
|
|
ProductCode: uniqueID("TPC"),
|
|
SalesPrice: 49.5,
|
|
Status: 1,
|
|
TenantId: "t_default_001",
|
|
}
|
|
|
|
res, err := m.Insert(ctx, p)
|
|
if err != nil {
|
|
t.Fatalf("Insert: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
t.Cleanup(func() { _ = m.Delete(ctx, id) })
|
|
|
|
got, err := m.FindOne(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("FindOne: %v", err)
|
|
}
|
|
if got.ProductName != p.ProductName {
|
|
t.Errorf("ProductName: got %q, want %q", got.ProductName, p.ProductName)
|
|
}
|
|
if got.SalesPrice != p.SalesPrice {
|
|
t.Errorf("SalesPrice: got %v, want %v", got.SalesPrice, p.SalesPrice)
|
|
}
|
|
}
|
|
|
|
// TestInvProductUpdate verifies Update writes back correctly.
|
|
func TestInvProductUpdate(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
m := newTestProductModel(t)
|
|
ctx := context.Background()
|
|
|
|
p := &InvProduct{
|
|
ProductId: uniqueID("pid"),
|
|
ProductName: uniqueID("test_product"),
|
|
Spec: "50cm",
|
|
Color: "green",
|
|
ColorNo: "01",
|
|
ProductCode: uniqueID("TPC"),
|
|
SalesPrice: 10.0,
|
|
Status: 1,
|
|
TenantId: "t_default_001",
|
|
}
|
|
res, err := m.Insert(ctx, p)
|
|
if err != nil {
|
|
t.Fatalf("Insert: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
t.Cleanup(func() { _ = m.Delete(ctx, id) })
|
|
|
|
got, err := m.FindOne(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("FindOne: %v", err)
|
|
}
|
|
got.SalesPrice = 25.0
|
|
got.Remark = sql.NullString{String: "updated", Valid: true}
|
|
|
|
if err := m.Update(ctx, got); err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
|
|
updated, err := m.FindOne(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("FindOne after Update: %v", err)
|
|
}
|
|
if updated.SalesPrice != 25.0 {
|
|
t.Errorf("SalesPrice after update: got %v, want 25.0", updated.SalesPrice)
|
|
}
|
|
if updated.Remark.String != "updated" {
|
|
t.Errorf("Remark after update: got %q, want \"updated\"", updated.Remark.String)
|
|
}
|
|
}
|
|
|
|
// TestInvProductBulkInsert verifies BulkInsert (used by the import flow).
|
|
func TestInvProductBulkInsert(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
m := newTestProductModel(t)
|
|
ctx := context.Background()
|
|
|
|
ids := make([]int64, 0, 3)
|
|
products := make([]*InvProduct, 3)
|
|
for i := range products {
|
|
products[i] = &InvProduct{
|
|
ProductId: uniqueID(fmt.Sprintf("bulk_pid_%d", i)),
|
|
ProductName: uniqueID(fmt.Sprintf("bulk_product_%d", i)),
|
|
Spec: "10cm",
|
|
Color: "white",
|
|
ColorNo: fmt.Sprintf("%02d", i+1),
|
|
ProductCode: uniqueID(fmt.Sprintf("BTPC%d", i)),
|
|
SalesPrice: float64(i+1) * 5.0,
|
|
Status: 1,
|
|
TenantId: "t_default_001",
|
|
}
|
|
}
|
|
|
|
affected, err := m.BulkInsert(ctx, products)
|
|
if err != nil {
|
|
t.Fatalf("BulkInsert: %v", err)
|
|
}
|
|
if affected != 3 {
|
|
t.Errorf("BulkInsert: affected=%d, want 3", affected)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
for _, p := range products {
|
|
row, err := m.FindOneByProductId(ctx, p.ProductId)
|
|
if err == nil {
|
|
_ = m.Delete(ctx, row.Id)
|
|
}
|
|
}
|
|
_ = ids
|
|
})
|
|
}
|
|
|
|
// TestInvProductSchemaColumns queries information_schema to assert the DB columns
|
|
// match what the InvProduct struct declares. Fails immediately on any mismatch.
|
|
func TestInvProductSchemaColumns(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
db, err := sql.Open("mysql", testDSN)
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
rows, err := db.QueryContext(context.Background(),
|
|
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='inv_product' ORDER BY ORDINAL_POSITION")
|
|
if err != nil {
|
|
t.Fatalf("query schema: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
dbCols := map[string]bool{}
|
|
for rows.Next() {
|
|
var col string
|
|
if err := rows.Scan(&col); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
dbCols[col] = true
|
|
}
|
|
|
|
// Columns that the InvProduct struct declares (via db tags).
|
|
structCols := []string{
|
|
"id", "product_id", "product_name", "image_url", "spec", "color",
|
|
"color_no", "product_code", "sales_price", "remark", "status", "tenant_id",
|
|
"created_at", "updated_at", "deleted_at",
|
|
}
|
|
|
|
for _, col := range structCols {
|
|
if !dbCols[col] {
|
|
t.Errorf("struct declares column %q but it does NOT exist in inv_product", col)
|
|
}
|
|
}
|
|
|
|
// Also check for DB columns not in the struct (forward-drift guard).
|
|
structSet := map[string]bool{}
|
|
for _, c := range structCols {
|
|
structSet[c] = true
|
|
}
|
|
for col := range dbCols {
|
|
if !structSet[col] {
|
|
t.Logf("WARNING: DB column %q exists but is not in the InvProduct struct", col)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFindProductSummaryNoPans guards against the GROUP_CONCAT NULL scan bug:
|
|
// when a product has no pans, the LEFT JOIN produces NULL for pan.position and
|
|
// GROUP_CONCAT over all-NULL values returns NULL, which cannot be scanned into a
|
|
// Go string. The fix wraps both GROUP_CONCAT calls with COALESCE(..., '').
|
|
func TestFindProductSummaryNoPans(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
m := newTestProductModel(t)
|
|
ctx := context.Background()
|
|
|
|
p := &InvProduct{
|
|
ProductId: uniqueID("pid_nopan"),
|
|
ProductName: uniqueID("nopan_product"),
|
|
Spec: "50cm",
|
|
Color: "red",
|
|
ColorNo: "01",
|
|
ProductCode: uniqueID("TPC"),
|
|
SalesPrice: 99.0,
|
|
Status: 1,
|
|
TenantId: "t_default_001",
|
|
}
|
|
res, err := m.Insert(ctx, p)
|
|
if err != nil {
|
|
t.Fatalf("Insert: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
t.Cleanup(func() { _ = m.Delete(ctx, id) })
|
|
|
|
list, err := m.FindProductSummary(ctx, "t_default_001")
|
|
if err != nil {
|
|
t.Fatalf("FindProductSummary returned error for product with no pans: %v", err)
|
|
}
|
|
|
|
var found *ProductSummary
|
|
for _, s := range list {
|
|
if s.ProductName == p.ProductName {
|
|
found = s
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("product %q not found in summary (len=%d)", p.ProductName, len(list))
|
|
}
|
|
if found.ColorCount != 1 {
|
|
t.Errorf("ColorCount: got %d, want 1", found.ColorCount)
|
|
}
|
|
if found.TotalPanCount != 0 {
|
|
t.Errorf("TotalPanCount: got %d, want 0", found.TotalPanCount)
|
|
}
|
|
if found.Locations != "" {
|
|
t.Errorf("Locations: got %q, want empty string", found.Locations)
|
|
}
|
|
}
|
|
|
|
// TestFindColorDetailNoPans is the same NULL guard for the color-detail query.
|
|
func TestFindColorDetailNoPans(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
m := newTestProductModel(t)
|
|
ctx := context.Background()
|
|
|
|
p := &InvProduct{
|
|
ProductId: uniqueID("pid_color"),
|
|
ProductName: uniqueID("color_nopan_product"),
|
|
Spec: "80cm",
|
|
Color: "blue",
|
|
ColorNo: "01",
|
|
ProductCode: uniqueID("TPC"),
|
|
SalesPrice: 50.0,
|
|
Status: 1,
|
|
TenantId: "t_default_001",
|
|
}
|
|
res, err := m.Insert(ctx, p)
|
|
if err != nil {
|
|
t.Fatalf("Insert: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
t.Cleanup(func() { _ = m.Delete(ctx, id) })
|
|
|
|
list, err := m.FindColorDetail(ctx, "t_default_001", p.ProductName)
|
|
if err != nil {
|
|
t.Fatalf("FindColorDetail returned error for product with no pans: %v", err)
|
|
}
|
|
if len(list) != 1 {
|
|
t.Fatalf("expected 1 color detail row, got %d", len(list))
|
|
}
|
|
if list[0].Color != "blue" {
|
|
t.Errorf("Color: got %q, want %q", list[0].Color, "blue")
|
|
}
|
|
if list[0].Locations != "" {
|
|
t.Errorf("Locations: got %q, want empty string", list[0].Locations)
|
|
}
|
|
}
|
|
|
|
func TestFindColorDetailBatchCountDoesNotDuplicateStock(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
conn := sqlx.NewMysql(testDSN)
|
|
m := newTestProductModel(t)
|
|
panM := newInvProductPanModel(conn)
|
|
boltM := newInvProductBoltModel(conn)
|
|
batchM := newInvProductBatchModel(conn)
|
|
ctx := context.Background()
|
|
|
|
productId := uniqueID("pcb")
|
|
p := &InvProduct{
|
|
ProductId: productId,
|
|
ProductName: uniqueID("color_batch_product"),
|
|
Spec: "120cm",
|
|
Color: "natural",
|
|
ColorNo: "01",
|
|
ProductCode: uniqueID("TPC"),
|
|
SalesPrice: 20.0,
|
|
Status: 1,
|
|
TenantId: "t_default_001",
|
|
}
|
|
res, err := m.Insert(ctx, p)
|
|
if err != nil {
|
|
t.Fatalf("Insert product: %v", err)
|
|
}
|
|
productRowId, _ := res.LastInsertId()
|
|
t.Cleanup(func() { _ = m.Delete(ctx, productRowId) })
|
|
|
|
batchAId := uniqueID("b1")
|
|
batchBId := uniqueID("b2")
|
|
for _, batch := range []*InvProductBatch{
|
|
{BatchId: batchAId, TenantId: p.TenantId, ProductId: productId, BatchNo: p.ProductCode + "-B001", Status: 1},
|
|
{BatchId: batchBId, TenantId: p.TenantId, ProductId: productId, BatchNo: p.ProductCode + "-B002", Status: 1},
|
|
} {
|
|
res, err := batchM.Insert(ctx, batch)
|
|
if err != nil {
|
|
t.Fatalf("Insert batch: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
t.Cleanup(func() { _ = batchM.Delete(ctx, id) })
|
|
}
|
|
|
|
pans := []*InvProductPan{
|
|
{PanId: uniqueID("pa"), ProductId: productId, BatchId: batchAId, Name: "A", Position: "A-01", SortOrder: 1, TenantId: p.TenantId},
|
|
{PanId: uniqueID("pb"), ProductId: productId, BatchId: batchBId, Name: "B", Position: "B-02", SortOrder: 2, TenantId: p.TenantId},
|
|
}
|
|
for _, pan := range pans {
|
|
res, err := panM.Insert(ctx, pan)
|
|
if err != nil {
|
|
t.Fatalf("Insert pan: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
t.Cleanup(func() { _ = panM.Delete(ctx, id) })
|
|
}
|
|
|
|
for _, bolt := range []*InvProductBolt{
|
|
{BoltId: uniqueID("ba1"), PanId: pans[0].PanId, LengthM: 10.5, Color: p.Color, SortOrder: 1, TenantId: p.TenantId},
|
|
{BoltId: uniqueID("ba2"), PanId: pans[0].PanId, LengthM: 11.5, Color: p.Color, SortOrder: 2, TenantId: p.TenantId},
|
|
{BoltId: uniqueID("bb1"), PanId: pans[1].PanId, LengthM: 12.0, Color: p.Color, SortOrder: 1, TenantId: p.TenantId},
|
|
} {
|
|
res, err := boltM.Insert(ctx, bolt)
|
|
if err != nil {
|
|
t.Fatalf("Insert bolt: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
t.Cleanup(func() { _ = boltM.Delete(ctx, id) })
|
|
}
|
|
|
|
list, err := m.FindColorDetail(ctx, p.TenantId, p.ProductName)
|
|
if err != nil {
|
|
t.Fatalf("FindColorDetail: %v", err)
|
|
}
|
|
if len(list) != 1 {
|
|
t.Fatalf("expected 1 color detail row, got %d", len(list))
|
|
}
|
|
got := list[0]
|
|
if got.PanCount != 2 {
|
|
t.Errorf("PanCount: got %d, want 2", got.PanCount)
|
|
}
|
|
if got.BoltCount != 3 {
|
|
t.Errorf("BoltCount: got %d, want 3", got.BoltCount)
|
|
}
|
|
if got.BatchCount != 2 {
|
|
t.Errorf("BatchCount: got %d, want 2", got.BatchCount)
|
|
}
|
|
if got.TotalLengthM != 34.0 {
|
|
t.Errorf("TotalLengthM: got %.2f, want 34.00", got.TotalLengthM)
|
|
}
|
|
}
|