package repository import ( "database/sql" "testing" "time" "github.com/wmms/wmms/internal/model" ) // seedOrderIsolation 在共享双公司 MySQL 测试集里播种两套完全独立的入出库单据与库存: // // 公司 A=101:物料 101、仓库 101、库位 101、入库单 101、出库单 101、余额 100; // 公司 B=102:物料 102、仓库 102、库位 102、入库单 102、出库单 102、余额 50。 // // 两家各自的单据、明细、余额、流水都指向本公司的维度,用来验证单据与库存冲销按公司隔离。 func seedOrderIsolation(t *testing.T, db *sql.DB) { t.Helper() mustSQL(t, db, `INSERT INTO organizations(id,code,name) VALUES (101,'OI_A','A'),(102,'OI_B','B'); INSERT INTO material_categories(id,organization_id,code,name) VALUES (101,101,'C','C'),(102,102,'C','C'); INSERT INTO units(id,organization_id,code,name) VALUES (101,101,'U','U'),(102,102,'U','U'); INSERT INTO materials(id,organization_id,material_code,name,category_id,base_unit_id) VALUES (101,101,'M','M',101,101),(102,102,'M','M',102,102); INSERT INTO warehouses(id,organization_id,code,name,status) VALUES (101,101,'W','W','active'),(102,102,'W','W','active'); INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES (101,101,'L','L','active'),(102,102,'L','L','active'); INSERT INTO inventory_balances(organization_id,warehouse_id,location_id,material_id,on_hand) VALUES (101,101,101,101,100),(102,102,102,102,50); INSERT INTO inbound_orders(id,organization_id,warehouse_id,location_id,inbound_date,handler,supplier) VALUES (101,101,101,101,NOW(),'A-in','supA'),(102,102,102,102,NOW(),'B-in','supB'); INSERT INTO inbound_items(inbound_order_id,material_id,quantity) VALUES (101,101,5),(102,102,7); INSERT INTO outbound_orders(id,organization_id,warehouse_id,location_id,outbound_date,handler,receiver) VALUES (101,101,101,101,NOW(),'A-out','recA'),(102,102,102,102,NOW(),'B-out','recB'); INSERT INTO outbound_items(outbound_order_id,material_id,quantity) VALUES (101,101,3),(102,102,4); INSERT INTO stock_logs(organization_id,warehouse_id,location_id,material_id,type,quantity,related_order_type,related_order_id) VALUES (101,101,101,101,'in',5,'inbound',101),(102,102,102,102,'in',7,'inbound',102);`) } func TestInboundRepoOrganizationIsolationReads(t *testing.T) { db := isolationDB(t, false) seedOrderIsolation(t, db) repo := NewInboundRepo() ownOrder, err := repo.GetByID(101, 101) if err != nil || ownOrder.ID != 101 || ownOrder.OrganizationID != 101 { t.Fatalf("A 读自己的单据失败: %+v err=%v", ownOrder, err) } if _, err := repo.GetByID(101, 102); err != sql.ErrNoRows { t.Fatalf("A 读 B 的单据必须 ErrNoRows, got %v", err) } // 列表与分页总数只含本公司。 ordersA, totalA, err := repo.List(101, "", 20, 0) if err != nil { t.Fatal(err) } if totalA != 1 || len(ordersA) != 1 || ordersA[0].ID != 101 { t.Fatalf("A 列表=%+v total=%d", ordersA, totalA) } ordersB, totalB, err := repo.List(102, "", 20, 0) if err != nil { t.Fatal(err) } if totalB != 1 || len(ordersB) != 1 || ordersB[0].ID != 102 { t.Fatalf("B 列表=%+v total=%d", ordersB, totalB) } // 按物料历史隔离:A 查到自己的 1 条;A 上下文读 B 的物料为空。 recordsA, err := repo.GetByMaterialID(101, 101) if err != nil || len(recordsA) != 1 { t.Fatalf("A 物料历史=%+v err=%v", recordsA, err) } recordsCross, err := repo.GetByMaterialID(101, 102) if err != nil || len(recordsCross) != 0 { t.Fatalf("A 读 B 物料历史必须为空, got %+v err=%v", recordsCross, err) } } // 头表 INSERT 显式保存三个维度;明细经已授权头表关联并限制同组织物料,跨公司物料的 // 明细读不出来。 func TestInboundRepoCreatePersistsDimensionsAndRestrictsItems(t *testing.T) { db := isolationDB(t, false) seedOrderIsolation(t, db) repo := NewInboundRepo() order := &model.InboundOrder{ OrganizationID: 101, WarehouseID: 101, LocationID: 101, InboundDate: time.Now(), Operator: "op", Handler: "h", Supplier: "s", Items: []model.InboundItem{{MaterialID: 101, Quantity: 2}}, } if err := repo.Create(order); err != nil { t.Fatal(err) } var org, warehouse, location int64 if err := db.QueryRow(`SELECT organization_id, warehouse_id, location_id FROM inbound_orders WHERE id = ?`, order.ID).Scan(&org, &warehouse, &location); err != nil { t.Fatal(err) } if org != 101 || warehouse != 101 || location != 101 { t.Fatalf("头表维度=%d/%d/%d, want 101/101/101", org, warehouse, location) } // 给 A 的单据塞一条 B 的物料明细:物料外键合法,但读取时的同组织关联会把它挡掉。 mustSQL(t, db, `INSERT INTO inbound_items(inbound_order_id, material_id, quantity) VALUES (?, ?, ?)`, order.ID, 102, 9) loaded, err := repo.GetByID(101, order.ID) if err != nil { t.Fatal(err) } if len(loaded.Items) != 1 || loaded.Items[0].MaterialID != 101 { t.Fatalf("跨公司物料明细必须被关联条件挡掉, got %+v", loaded.Items) } } // A 改/删 B 的单据:A 上下文命中 ErrNoRows 或影响 0 行,B 的头表与明细都不变。 func TestInboundRepoUpdateAndDeleteScopeToOrganization(t *testing.T) { db := isolationDB(t, false) seedOrderIsolation(t, db) repo := NewInboundRepo() if err := repo.Update(101, 102, &model.UpdateInboundRequest{Handler: "hacked", Supplier: "hacked"}); err != sql.ErrNoRows { t.Fatalf("A 改 B 的单据必须 ErrNoRows, got %v", err) } var handler, supplier string if err := db.QueryRow(`SELECT handler, supplier FROM inbound_orders WHERE id = 102`).Scan(&handler, &supplier); err != nil { t.Fatal(err) } if handler != "B-in" || supplier != "supB" { t.Fatalf("B 的单据头被改动: %s/%s", handler, supplier) } var itemCount int if err := db.QueryRow(`SELECT COUNT(*) FROM inbound_items WHERE inbound_order_id = 102`).Scan(&itemCount); err != nil { t.Fatal(err) } if itemCount != 1 { t.Fatalf("B 的明细被改动: %d 行", itemCount) } if err := repo.Delete(101, 102); err != nil { t.Fatal(err) } var orderCount int if err := db.QueryRow(`SELECT COUNT(*) FROM inbound_orders WHERE id = 102`).Scan(&orderCount); err != nil { t.Fatal(err) } if orderCount != 1 { t.Fatal("A 删掉了 B 的单据") } } // 库存流水删除同时约束组织、关联类型和单据 ID:凭别家公司的单据 ID 删不掉它的流水, // 本公司相同单据 ID 的流水仍能正常删除。 func TestStockRepoDeleteLogsScopesToOrganization(t *testing.T) { db := isolationDB(t, false) seedOrderIsolation(t, db) repo := NewStockRepo() if err := repo.DeleteLogs(101, "inbound", 102); err != nil { t.Fatal(err) } var count int if err := db.QueryRow(`SELECT COUNT(*) FROM stock_logs WHERE organization_id = 102 AND related_order_type = 'inbound' AND related_order_id = 102`).Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Fatal("凭 B 的单据 ID 删掉了它的库存流水") } if err := repo.DeleteLogs(101, "inbound", 101); err != nil { t.Fatal(err) } if err := db.QueryRow(`SELECT COUNT(*) FROM stock_logs WHERE organization_id = 101 AND related_order_type = 'inbound' AND related_order_id = 101`).Scan(&count); err != nil { t.Fatal(err) } if count != 0 { t.Fatalf("本公司流水未被删除: %d", count) } }