176 lines
7.2 KiB
Go
176 lines
7.2 KiB
Go
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)
|
||
}
|
||
}
|