From b0cab477dbae0fb2731d37a7a521d1e4c548c8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Fri, 9 Oct 2026 12:21:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E5=85=AC=E5=8F=B8=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E5=85=A5=E5=87=BA=E5=BA=93=E5=8D=95=E6=8D=AE=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/api.go | 70 ++++- .../handler/order_flow_integration_test.go | 231 +++++++++++++++++ internal/handler/order_organization_test.go | 244 ++++++++++++++++++ internal/handler/websocket.go | 21 +- internal/model/model.go | 18 +- internal/repository/inbound.go | 126 ++++++--- .../repository/inbound_organization_test.go | 175 +++++++++++++ internal/repository/order_null_fields_test.go | 26 +- internal/repository/outbound.go | 119 ++++++--- .../repository/outbound_organization_test.go | 119 +++++++++ internal/repository/stock.go | 16 +- internal/service/inbound.go | 63 +++-- internal/service/inbound_test.go | 72 +++++- internal/service/outbound.go | 57 ++-- internal/service/outbound_test.go | 78 +++++- 15 files changed, 1259 insertions(+), 176 deletions(-) create mode 100644 internal/handler/order_flow_integration_test.go create mode 100644 internal/handler/order_organization_test.go create mode 100644 internal/repository/inbound_organization_test.go create mode 100644 internal/repository/outbound_organization_test.go diff --git a/internal/handler/api.go b/internal/handler/api.go index 5ef95592..60225af3 100644 --- a/internal/handler/api.go +++ b/internal/handler/api.go @@ -476,7 +476,12 @@ func (h *Handler) ListInbound(c *gin.Context) { } } - orders, total, err := h.inboundSvc.List(search, limit, offset) + orgID, ok := organizationID(c) + if !ok { + return + } + + orders, total, err := h.inboundSvc.List(orgID, search, limit, offset) if err != nil { response.InternalError(c, err.Error()) return @@ -494,7 +499,12 @@ func (h *Handler) GetInbound(c *gin.Context) { return } - order, err := h.inboundSvc.GetByID(id) + orgID, ok := organizationID(c) + if !ok { + return + } + + order, err := h.inboundSvc.GetByID(orgID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Inbound order not found") @@ -539,8 +549,8 @@ func (h *Handler) CreateInbound(c *gin.Context) { response.InternalError(c, err.Error()) return } - // Broadcast stock update to all connected clients - GetHub().BroadcastStockUpdate("inbound", order.ID, order) + // Broadcast stock update to all connected clients(带上公司归属,供按公司投递过滤) + GetHub().BroadcastStockUpdate("inbound", orgID, order.ID, order) response.Created(c, "Inbound order created successfully", order) } @@ -558,6 +568,10 @@ func (h *Handler) DeleteInbound(c *gin.Context) { err = h.inboundSvc.Delete(orgID, id) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + response.NotFound(c, "Inbound order not found") + return + } if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return @@ -588,6 +602,10 @@ func (h *Handler) UpdateInbound(c *gin.Context) { order, err := h.inboundSvc.Update(orgID, id, &req) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + response.NotFound(c, "Inbound order not found") + return + } if errors.Is(err, service.ErrMaterialNotFound) { response.BadRequest(c, "material not found") return @@ -603,7 +621,7 @@ func (h *Handler) UpdateInbound(c *gin.Context) { response.InternalError(c, err.Error()) return } - GetHub().BroadcastStockUpdate("inbound", order.ID, order) + GetHub().BroadcastStockUpdate("inbound", orgID, order.ID, order) response.Success(c, order) } @@ -614,7 +632,12 @@ func (h *Handler) GetInboundByMaterial(c *gin.Context) { return } - records, err := h.inboundSvc.GetByMaterialID(materialID) + orgID, ok := organizationID(c) + if !ok { + return + } + + records, err := h.inboundSvc.GetByMaterialID(orgID, materialID) if err != nil { response.InternalError(c, err.Error()) return @@ -646,7 +669,12 @@ func (h *Handler) ListOutbound(c *gin.Context) { } } - orders, total, err := h.outboundSvc.List(search, limit, offset) + orgID, ok := organizationID(c) + if !ok { + return + } + + orders, total, err := h.outboundSvc.List(orgID, search, limit, offset) if err != nil { response.InternalError(c, err.Error()) return @@ -664,7 +692,12 @@ func (h *Handler) GetOutbound(c *gin.Context) { return } - order, err := h.outboundSvc.GetByID(id) + orgID, ok := organizationID(c) + if !ok { + return + } + + order, err := h.outboundSvc.GetByID(orgID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { response.NotFound(c, "Outbound order not found") @@ -713,8 +746,8 @@ func (h *Handler) CreateOutbound(c *gin.Context) { response.InternalError(c, err.Error()) return } - // Broadcast stock update to all connected clients - GetHub().BroadcastStockUpdate("outbound", order.ID, order) + // Broadcast stock update to all connected clients(带上公司归属,供按公司投递过滤) + GetHub().BroadcastStockUpdate("outbound", orgID, order.ID, order) response.Created(c, "Outbound order created successfully", order) } @@ -732,6 +765,10 @@ func (h *Handler) DeleteOutbound(c *gin.Context) { err = h.outboundSvc.Delete(orgID, id) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + response.NotFound(c, "Outbound order not found") + return + } if errors.Is(err, service.ErrInventoryScopeUnavailable) { response.Error(c, 409, err.Error()) return @@ -766,6 +803,10 @@ func (h *Handler) UpdateOutbound(c *gin.Context) { } order, err := h.outboundSvc.Update(orgID, id, &req, operator) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + response.NotFound(c, "Outbound order not found") + return + } if errors.Is(err, service.ErrMaterialNotFound) { response.BadRequest(c, "material not found") return @@ -785,7 +826,7 @@ func (h *Handler) UpdateOutbound(c *gin.Context) { response.InternalError(c, err.Error()) return } - GetHub().BroadcastStockUpdate("outbound", order.ID, order) + GetHub().BroadcastStockUpdate("outbound", orgID, order.ID, order) response.Success(c, order) } @@ -796,7 +837,12 @@ func (h *Handler) GetOutboundByMaterial(c *gin.Context) { return } - records, err := h.outboundSvc.GetByMaterialID(materialID) + orgID, ok := organizationID(c) + if !ok { + return + } + + records, err := h.outboundSvc.GetByMaterialID(orgID, materialID) if err != nil { response.InternalError(c, err.Error()) return diff --git a/internal/handler/order_flow_integration_test.go b/internal/handler/order_flow_integration_test.go new file mode 100644 index 00000000..2318554d --- /dev/null +++ b/internal/handler/order_flow_integration_test.go @@ -0,0 +1,231 @@ +package handler + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-sql-driver/mysql" + "github.com/wmms/wmms/pkg/database" +) + +// TestOrderFlowIsolatesCompanyInventoryMySQL 用一张一次性 MySQL 库做端到端验收: +// A 公司的入库/出库/编辑/删除只改变 A 公司的库存与单据,B 公司完全不受影响; +// 库存不足、仓库停用等失败不会留下半张单据或孤立流水。真实库用例,未配 TEST_DB_DSN 时 skip。 +func TestOrderFlowIsolatesCompanyInventoryMySQL(t *testing.T) { + dsn := os.Getenv("TEST_DB_DSN") + if dsn == "" { + t.Skip("real MySQL integration requires TEST_DB_DSN") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(cfg.DBName, "company_isolation_test") { + t.Fatal("TEST_DB_DSN must name a company_isolation_test schema") + } + cfg.DBName = "" + cfg.MultiStatements = true + cfg.ParseTime = true + admin, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + name := fmt.Sprintf("company_isolation_test_order_%d", time.Now().UnixNano()) + if _, err = admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + admin.Close() + t.Fatal(err) + } + cfg.DBName = name + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + admin.Exec("DROP DATABASE `" + name + "`") + admin.Close() + t.Fatal(err) + } + previous := database.DB + database.DB = db + t.Cleanup(func() { database.DB = previous; db.Close(); admin.Exec("DROP DATABASE `" + name + "`"); admin.Close() }) + + content, err := os.ReadFile(filepath.Join("..", "..", "scripts", "init.sql")) + if err != nil { + t.Fatal(err) + } + var lines []string + for _, line := range strings.Split(strings.TrimPrefix(string(content), "\ufeff"), "\n") { + if strings.HasPrefix(line, "CREATE DATABASE") || strings.HasPrefix(line, "USE ") { + continue + } + lines = append(lines, line) + } + exec := func(query string, args ...interface{}) { + t.Helper() + if _, err := db.Exec(query, args...); err != nil { + t.Fatalf("fixture SQL: %v\n%s", err, query) + } + } + exec(strings.Join(lines, "\n")) + // 公司 A=201、B=202 各配一组启用仓库、库位与一个物料,余额 A=10、B=50; + // 用户 7 有 warehouse_admin 角色,并同时被授权公司 A、B。 + exec(`INSERT INTO organizations(id,code,name,status) VALUES (201,'OI_A','公司A','active'),(202,'OI_B','公司B','active'); + INSERT INTO users(id,username,password) VALUES (7,'order-user','x'); + INSERT INTO user_roles(user_id,role_id) SELECT 7,id FROM roles WHERE code='warehouse_admin'; + INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES (7,201,1),(7,202,0); + INSERT INTO material_categories(id,organization_id,code,name) VALUES (201,201,'C','C'),(202,202,'C','C'); + INSERT INTO units(id,organization_id,code,name) VALUES (201,201,'U','U'),(202,202,'U','U'); + INSERT INTO materials(id,organization_id,material_code,name,spec,unit,category_id,base_unit_id) VALUES (201,201,'MA','A','','pcs',201,201),(202,202,'MB','B','','pcs',202,202); + INSERT INTO warehouses(id,organization_id,code,name,status) VALUES (201,201,'W','W','active'),(202,202,'W','W','active'); + INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES (201,201,'L','L','active'),(202,202,'L','L','active'); + INSERT INTO inventory_balances(organization_id,warehouse_id,location_id,material_id,on_hand) VALUES (201,201,201,201,10),(202,202,202,202,50);`) + + token := contextToken(t) + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler().RegisterRoutes(router) + + request := func(method, path, body string, org int64) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Organization-ID", fmt.Sprint(org)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + expectStatus := func(w *httptest.ResponseRecorder, status int) { + t.Helper() + if w.Code != status { + t.Fatalf("status=%d body=%s, want %d", w.Code, w.Body.String(), status) + } + } + balance := func(org, material int64) string { + t.Helper() + var onHand string + if err := db.QueryRow(`SELECT COALESCE(on_hand,0) FROM inventory_balances WHERE organization_id=? AND material_id=?`, org, material).Scan(&onHand); err != nil { + t.Fatal(err) + } + return onHand + } + orderCount := func(table string, org int64) int { + t.Helper() + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE organization_id=?`, org).Scan(&count); err != nil { + t.Fatal(err) + } + return count + } + orphanLogs := func(org int64) int { + t.Helper() + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM stock_logs sl WHERE sl.organization_id=? AND ((sl.related_order_type='inbound' AND sl.related_order_id NOT IN (SELECT id FROM inbound_orders WHERE organization_id=?)) OR (sl.related_order_type='outbound' AND sl.related_order_id NOT IN (SELECT id FROM outbound_orders WHERE organization_id=?)))`, org, org, org).Scan(&count); err != nil { + t.Fatal(err) + } + return count + } + aBalanceSteps := []struct{ label, want string }{ + {"初始", "10.000000"}, + {"A 入库 5 后", "15.000000"}, + {"A 出库 3 后", "12.000000"}, + {"A 编辑入库 5→8 后", "15.000000"}, + {"A 删除入库单后", "7.000000"}, + } + if got := balance(201, 201); got != aBalanceSteps[0].want { + t.Fatalf("初始 A 余额=%s, want %s", got, aBalanceSteps[0].want) + } + if got := balance(202, 202); got != "50.000000" { + t.Fatalf("初始 B 余额=%s, want 50.000000", got) + } + + // 1) A 入库 5。 + w := request("POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":201,"quantity":5}]}`, 201) + expectStatus(w, 201) + var inbound struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &inbound); err != nil || inbound.Data.ID == 0 { + t.Fatalf("解析入库单失败: %v body=%s", err, w.Body.String()) + } + if got := balance(201, 201); got != aBalanceSteps[1].want { + t.Fatalf("A 入库后余额=%s, want %s", got, aBalanceSteps[1].want) + } + + // 2) A 出库 3。 + expectStatus(request("POST", "/api/outbound", `{"receiver":"r","items":[{"material_id":201,"quantity":3}]}`, 201), 201) + if got := balance(201, 201); got != aBalanceSteps[2].want { + t.Fatalf("A 出库后余额=%s, want %s", got, aBalanceSteps[2].want) + } + + // 3) A 编辑入库单 5→8。 + expectStatus(request("PUT", fmt.Sprintf("/api/inbound/%d", inbound.Data.ID), `{"handler":"h","supplier":"s","items":[{"material_id":201,"quantity":8}]}`, 201), 200) + if got := balance(201, 201); got != aBalanceSteps[3].want { + t.Fatalf("A 编辑后余额=%s, want %s", got, aBalanceSteps[3].want) + } + + // 4) A 删除入库单。 + expectStatus(request("DELETE", fmt.Sprintf("/api/inbound/%d", inbound.Data.ID), "", 201), 200) + if got := balance(201, 201); got != aBalanceSteps[4].want { + t.Fatalf("A 删除后余额=%s, want %s", got, aBalanceSteps[4].want) + } + + // 至此 A 的库存与单据变化只落在 A 上,B 一直是 50、无单据无流水。 + if got := balance(202, 202); got != "50.000000" { + t.Fatalf("A 操作后 B 余额=%s, want 50.000000", got) + } + if orderCount("inbound_orders", 202) != 0 || orderCount("outbound_orders", 202) != 0 { + t.Fatalf("A 操作影响了 B 的单据") + } + if got := orderCount("outbound_orders", 201); got != 1 { + t.Fatalf("A 出库单数=%d, want 1", got) + } + + // 5) 库存不足:A 出库 100 失败,余额不变,不留半张出库单或孤立流水。 + logsBefore := countLogs(t, db, 201) + expectStatus(request("POST", "/api/outbound", `{"receiver":"r","items":[{"material_id":201,"quantity":100}]}`, 201), 400) + if got := balance(201, 201); got != aBalanceSteps[4].want { + t.Fatalf("库存不足后 A 余额=%s, want %s", got, aBalanceSteps[4].want) + } + if got := orderCount("outbound_orders", 201); got != 1 { + t.Fatalf("库存不足留下了半张出库单: %d 张", got) + } + if countLogs(t, db, 201) != logsBefore || orphanLogs(201) != 0 { + t.Fatalf("库存不足留下了孤立流水") + } + + // 6) 仓库停用:改变库存的入库请求回 409,不落任何单据。 + exec(`UPDATE warehouses SET status='inactive' WHERE organization_id=201`) + logsBefore = countLogs(t, db, 201) + expectStatus(request("POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":201,"quantity":1}]}`, 201), 409) + if got := orderCount("inbound_orders", 201); got != 0 { + t.Fatalf("仓库停用留下了半张入库单: %d 张", got) + } + if countLogs(t, db, 201) != logsBefore || orphanLogs(201) != 0 { + t.Fatalf("仓库停用留下了孤立流水") + } + if got := balance(201, 201); got != aBalanceSteps[4].want { + t.Fatalf("仓库停用后 A 余额=%s, want %s", got, aBalanceSteps[4].want) + } + if got := balance(202, 202); got != "50.000000" { + t.Fatalf("失败操作影响了 B 余额: %s", got) + } +} + +func countLogs(t *testing.T, db *sql.DB, organizationID int64) int { + t.Helper() + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM stock_logs WHERE organization_id=?`, organizationID).Scan(&count); err != nil { + t.Fatal(err) + } + return count +} diff --git a/internal/handler/order_organization_test.go b/internal/handler/order_organization_test.go new file mode 100644 index 00000000..79ebcac0 --- /dev/null +++ b/internal/handler/order_organization_test.go @@ -0,0 +1,244 @@ +package handler + +import ( + "strconv" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +// 本文件验收 Task 5B 的入出库单据接口隔离: +// 1. 单据详情、按物料历史、编辑、删除都带请求头公司,A 读/改/删 B 的单据返回 404; +// 2. 未配置启用仓库、库位时,改变库存的新建请求返回 409 与具体配置提示。 +// +// 中间件链:AuthMiddleware → OrganizationMiddleware → 业务 Handler,所有 SQL 期望 +// 都必须带对组织参数,参数对不上 sqlmock 直接报错。 +// +// 与 repository 里的查询字面量对得上的稳定子串。 +const ( + inboundHeadPattern = `SELECT id, COALESCE\(inbound_no` + outboundHeadPattern = `SELECT id, COALESCE\(outbound_no` + inboundByMatPattern = `SELECT ii.id, ii.inbound_order_id` + outboundByMatPattern = `SELECT oi.id, oi.outbound_order_id` + orderWarehousePattern = `SELECT w.id, w.organization_id` +) + +func orderHeadRow(prefix string, id, organizationID int64) *sqlmock.Rows { + now := time.Now() + return sqlmock.NewRows([]string{"id", "order_no", "operator", "order_date", "handler", "party", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"}). + AddRow(id, prefix+strconv.FormatInt(id, 10), "", now, "h", "p", "", organizationID, 10, 20, now, now) +} + +func emptyOrderHeadRows() *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "order_no", "operator", "order_date", "handler", "party", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"}) +} + +func emptyInboundRecords() *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "inbound_id", "handler", "supplier", "quantity", "created_at"}) +} + +func emptyOutboundRecords() *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "outbound_id", "handler", "receiver", "quantity", "created_at"}) +} + +func emptyOrderItems() *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "order_id", "material_id", "quantity", "material_code", "name", "status"}) +} + +func emptyWarehouseSummary() *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"}) +} + +func activeMaterialRows(id int64) *sqlmock.Rows { + now := time.Now() + return sqlmock.NewRows(materialColumns()). + AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, 1, 1, "个", "active", 1, now, now) +} + +// TestOrderDetailScopesToOrganization:单据详情按请求公司过滤,传别家公司的单据 ID 读不到,回 404。 +func TestOrderDetailScopesToOrganization(t *testing.T) { + for _, tc := range []struct { + name string + id int64 + path string + head string + items string + found bool + prefix string + wantCode int + }{ + {"inbound own", 101, "/api/inbound/101", inboundHeadPattern, inboundByMatPattern, true, "IN-", 200}, + {"inbound cross", 102, "/api/inbound/102", inboundHeadPattern, inboundByMatPattern, false, "IN-", 404}, + {"outbound own", 101, "/api/outbound/101", outboundHeadPattern, outboundByMatPattern, true, "OUT-", 200}, + {"outbound cross", 102, "/api/outbound/102", outboundHeadPattern, outboundByMatPattern, false, "OUT-", 404}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := contextMock(t) + expectContextPrincipal(mock, "warehouse_admin") + expectMaterialOrgAccess(mock, 1) + headRows := emptyOrderHeadRows() + if tc.found { + headRows = orderHeadRow(tc.prefix, tc.id, 1) + } + mock.ExpectQuery(tc.head).WithArgs(tc.id, int64(1)).WillReturnRows(headRows) + if tc.found { + mock.ExpectQuery(tc.items).WithArgs(tc.id).WillReturnRows(emptyOrderItems()) + } + + w := newStockRequest(t, "GET", tc.path, "", 1) + if w.Code != tc.wantCode { + t.Fatalf("status=%d body=%s, want %d", w.Code, w.Body.String(), tc.wantCode) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +// TestOrderByMaterialScopesToOrganization:按物料历史记录带请求公司查询, +// A 上下文读 B 的物料只会拿到空列表,不泄露别家公司的入出库记录。 +func TestOrderByMaterialScopesToOrganization(t *testing.T) { + for _, tc := range []struct { + name string + path string + pattern string + rows func() *sqlmock.Rows + }{ + {"inbound by material", "/api/inbound/materials/102", inboundByMatPattern, emptyInboundRecords}, + {"outbound by material", "/api/outbound/materials/102", outboundByMatPattern, emptyOutboundRecords}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := contextMock(t) + expectContextPrincipal(mock, "warehouse_admin") + expectMaterialOrgAccess(mock, 1) + // 物料 102 属于公司 2:带 organization_id=1 的历史查询参数必须是 (102, 1)。 + mock.ExpectQuery(tc.pattern).WithArgs(int64(102), int64(1)).WillReturnRows(tc.rows()) + + w := newStockRequest(t, "GET", tc.path, "", 1) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +// TestOrderDeleteCrossOrganizationReturnsNotFound:删除别家公司的单据时,事务内锁定 +// 当前公司原单据命中 ErrNoRows,接口回 404,且不执行任何冲销。 +func TestOrderDeleteCrossOrganizationReturnsNotFound(t *testing.T) { + for _, tc := range []struct { + name string + path string + pattern string + }{ + {"inbound", "/api/inbound/102", inboundHeadPattern}, + {"outbound", "/api/outbound/102", outboundHeadPattern}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := contextMock(t) + expectContextPrincipal(mock, "warehouse_admin") + expectMaterialOrgAccess(mock, 1) + mock.ExpectBegin() + mock.ExpectQuery(tc.pattern).WithArgs(int64(102), int64(1)).WillReturnRows(emptyOrderHeadRows()) + mock.ExpectRollback() + + w := newStockRequest(t, "DELETE", tc.path, "", 1) + if w.Code != 404 { + t.Fatalf("status=%d body=%s, want 404", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +// TestOrderUpdateCrossOrganizationReturnsNotFound:编辑别家公司的单据时,读取原单据 +// 命中 ErrNoRows,接口回 404,不落任何改动。 +func TestOrderUpdateCrossOrganizationReturnsNotFound(t *testing.T) { + body := `{"items":[{"material_id":7,"quantity":1}]}` + for _, tc := range []struct { + name string + path string + pattern string + }{ + {"inbound", "/api/inbound/102", inboundHeadPattern}, + {"outbound", "/api/outbound/102", outboundHeadPattern}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := contextMock(t) + expectContextPrincipal(mock, "warehouse_admin") + expectMaterialOrgAccess(mock, 1) + mock.ExpectQuery(tc.pattern).WithArgs(int64(102), int64(1)).WillReturnRows(emptyOrderHeadRows()) + + w := newStockRequest(t, "PUT", tc.path, body, 1) + if w.Code != 404 { + t.Fatalf("status=%d body=%s, want 404", w.Code, w.Body.String()) + } + // 只登记了归属读取:一旦越过它去改明细,sqlmock 会因未预期语句报错。 + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +// TestCreateOrderWithoutWarehouseReturnsConflict:改变库存的新建请求在公司未配置 +// 启用仓库、库位时回 409 与具体配置提示,不回退默认公司。 +func TestCreateOrderWithoutWarehouseReturnsConflict(t *testing.T) { + inboundBody := `{"handler":"h","supplier":"s","items":[{"material_id":5,"quantity":1}]}` + outboundBody := `{"receiver":"r","items":[{"material_id":5,"quantity":1}]}` + + t.Run("inbound", func(t *testing.T) { + mock := contextMock(t) + expectContextPrincipal(mock, "warehouse_admin") + expectMaterialOrgAccess(mock, 1) + // 入库先校验明细物料,再解析库存维度;物料合法但公司无仓库 → 409。 + mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(5)).WillReturnRows(activeMaterialRows(5)) + mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(5)).WillReturnRows(activeMaterialRows(5)) + mock.ExpectQuery(orderWarehousePattern).WithArgs(int64(1)).WillReturnRows(emptyWarehouseSummary()) + + w := newStockRequest(t, "POST", "/api/inbound", inboundBody, 1) + if w.Code != 409 || !strings.Contains(w.Body.String(), "请先配置本公司仓库") { + t.Fatalf("status=%d body=%s, want 409 with 配置提示", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + + t.Run("outbound", func(t *testing.T) { + mock := contextMock(t) + expectContextPrincipal(mock, "warehouse_admin") + expectMaterialOrgAccess(mock, 1) + // 出库先解析库存维度:公司无仓库直接 409,不会再校验明细。 + mock.ExpectQuery(orderWarehousePattern).WithArgs(int64(1)).WillReturnRows(emptyWarehouseSummary()) + + w := newStockRequest(t, "POST", "/api/outbound", outboundBody, 1) + if w.Code != 409 || !strings.Contains(w.Body.String(), "请先配置本公司仓库") { + t.Fatalf("status=%d body=%s, want 409 with 配置提示", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) +} + +// TestBroadcastStockUpdateCarriesOrganizationID:库存事件带上公司 ID,供 Task 5D 按公司投递。 +func TestBroadcastStockUpdateCarriesOrganizationID(t *testing.T) { + hub := &Hub{broadcast: make(chan []byte, 1)} + hub.BroadcastStockUpdate("inbound", 7, 3, nil) + select { + case msg := <-hub.broadcast: + if !strings.Contains(string(msg), `"organization_id":7`) { + t.Fatalf("事件未携带公司 ID: %s", string(msg)) + } + default: + t.Fatal("没有广播任何事件") + } +} diff --git a/internal/handler/websocket.go b/internal/handler/websocket.go index 3537e7b9..5f8d5e3a 100644 --- a/internal/handler/websocket.go +++ b/internal/handler/websocket.go @@ -36,10 +36,12 @@ type Hub struct { // StockUpdateEvent represents a stock update notification type StockUpdateEvent struct { - Type string `json:"type"` // "stock_update" - Action string `json:"action"` // "inbound" or "outbound" - OrderID int64 `json:"order_id"` - Data interface{} `json:"data,omitempty"` + Type string `json:"type"` // "stock_update" + Action string `json:"action"` // "inbound" or "outbound" + // OrganizationID 是本次库存变化所属公司,供按公司投递过滤(Task 5D 按它路由)。 + OrganizationID int64 `json:"organization_id"` + OrderID int64 `json:"order_id"` + Data interface{} `json:"data,omitempty"` } var hubInstance *Hub @@ -93,12 +95,13 @@ func (h *Hub) run() { } // BroadcastStockUpdate sends a stock update event to all connected clients -func (h *Hub) BroadcastStockUpdate(action string, orderID int64, data interface{}) { +func (h *Hub) BroadcastStockUpdate(action string, organizationID, orderID int64, data interface{}) { event := StockUpdateEvent{ - Type: "stock_update", - Action: action, - OrderID: orderID, - Data: data, + Type: "stock_update", + Action: action, + OrganizationID: organizationID, + OrderID: orderID, + Data: data, } msg, err := json.Marshal(event) if err != nil { diff --git a/internal/model/model.go b/internal/model/model.go index 1d9ab2f6..565040c1 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -63,8 +63,13 @@ type InboundOrder struct { Handler string `json:"handler"` Supplier string `json:"supplier"` Remark string `json:"remark"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + // OrganizationID/WarehouseID/LocationID 是单据落库的库存维度,由服务端按当前 + // 公司解析后填写,不接受前端伪造的归属。 + OrganizationID int64 `json:"organization_id"` + WarehouseID int64 `json:"warehouse_id"` + LocationID int64 `json:"location_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at,omitempty"` // Status 不落库:inbound_orders 表没有 status 列,状态由查询层统一写死为 "completed" // (单据创建即完成,当前没有中间态)。保留字段是为了稳定 JSON 契约, // 将来引入状态机时再改为真实读写。 @@ -92,8 +97,13 @@ type OutboundOrder struct { Handler string `json:"handler"` Receiver string `json:"receiver"` Remark string `json:"remark"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + // OrganizationID/WarehouseID/LocationID 是单据落库的库存维度,由服务端按当前 + // 公司解析后填写,不接受前端伪造的归属。 + OrganizationID int64 `json:"organization_id"` + WarehouseID int64 `json:"warehouse_id"` + LocationID int64 `json:"location_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at,omitempty"` // Status 不落库:outbound_orders 表没有 status 列,状态由查询层统一写死为 "completed" // (单据创建即完成,当前没有中间态)。保留字段是为了稳定 JSON 契约, // 将来引入状态机时再改为真实读写。 diff --git a/internal/repository/inbound.go b/internal/repository/inbound.go index a854cf64..cb4a34b5 100644 --- a/internal/repository/inbound.go +++ b/internal/repository/inbound.go @@ -8,6 +8,13 @@ import ( "github.com/wmms/wmms/pkg/database" ) +// orderQuerier 同时被 *sql.DB 与 *sql.Tx 满足,让单据头表加明细的读取逻辑能在 +// 事务内外复用:事务内用 FOR UPDATE 锁定当前公司的原单据,事务外只做普通读取。 +type orderQuerier interface { + Query(query string, args ...interface{}) (*sql.Rows, error) + QueryRow(query string, args ...interface{}) *sql.Row +} + type InboundRepo struct{} func NewInboundRepo() *InboundRepo { @@ -32,8 +39,10 @@ func (r *InboundRepo) CreateTx(tx *sql.Tx, order *model.InboundOrder) error { defer tx.Rollback() } - query := `INSERT INTO inbound_orders (inbound_date, operator, handler, supplier, remark) VALUES (?, ?, ?, ?, ?)` - result, err := tx.Exec(query, order.InboundDate, order.Operator, order.Handler, order.Supplier, order.Remark) + // 三个库存维度由服务端解析后显式落库;表上这三列 NOT NULL 无默认值,缺一即报错, + // 不会静默写进默认公司。 + query := `INSERT INTO inbound_orders (organization_id, warehouse_id, location_id, inbound_date, operator, handler, supplier, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + result, err := tx.Exec(query, order.OrganizationID, order.WarehouseID, order.LocationID, order.InboundDate, order.Operator, order.Handler, order.Supplier, order.Remark) if err != nil { return err } @@ -68,31 +77,47 @@ func (r *InboundRepo) CreateTx(tx *sql.Tx, order *model.InboundOrder) error { return nil } -func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) { - order := &model.InboundOrder{} - query := `SELECT id, COALESCE(inbound_no, CONCAT('IN-', LPAD(id, 8, '0'))), COALESCE(operator, ''), inbound_date, handler, supplier, COALESCE(remark, ''), created_at, updated_at FROM inbound_orders WHERE id = ?` - err := database.DB.QueryRow(query, id).Scan(&order.ID, &order.InboundNo, &order.Operator, &order.InboundDate, &order.Handler, &order.Supplier, &order.Remark, &order.CreatedAt, &order.UpdatedAt) - if err != nil { - return nil, err - } - +// loadInboundItems 读取单据明细,并 JOIN 头表与物料表把明细限制在同一组织内: +// 头表已按组织过滤,明细物料也必须属于同一公司,避免读到跨公司的物料行。 +func loadInboundItems(q orderQuerier, orderID int64) ([]model.InboundItem, error) { itemsQuery := `SELECT ii.id, ii.inbound_order_id, ii.material_id, ii.quantity, m.material_code, m.name, m.status FROM inbound_items ii - JOIN materials m ON ii.material_id = m.id + JOIN inbound_orders io ON io.id = ii.inbound_order_id + JOIN materials m ON m.id = ii.material_id AND m.organization_id = io.organization_id WHERE ii.inbound_order_id = ?` - rows, err := database.DB.Query(itemsQuery, id) + rows, err := q.Query(itemsQuery, orderID) if err != nil { return nil, err } defer rows.Close() + var items []model.InboundItem for rows.Next() { var item model.InboundItem - err := rows.Scan(&item.ID, &item.InboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus) - if err != nil { + if err := rows.Scan(&item.ID, &item.InboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus); err != nil { return nil, err } - order.Items = append(order.Items, item) + items = append(items, item) + } + return items, rows.Err() +} + +// getByID 按公司读取单据头表加明细。lock=true 时用 FOR UPDATE 锁定当前公司的原单据, +// 供更新/删除事务在同一把锁下冲销原库存。别的公司的单据在这里命中 sql.ErrNoRows。 +func (r *InboundRepo) getByID(q orderQuerier, organizationID, id int64, lock bool) (*model.InboundOrder, error) { + order := &model.InboundOrder{} + query := `SELECT id, COALESCE(inbound_no, CONCAT('IN-', LPAD(id, 8, '0'))), COALESCE(operator, ''), inbound_date, handler, supplier, COALESCE(remark, ''), organization_id, warehouse_id, location_id, created_at, updated_at FROM inbound_orders WHERE id = ? AND organization_id = ?` + if lock { + query += ` FOR UPDATE` + } + err := q.QueryRow(query, id, organizationID).Scan(&order.ID, &order.InboundNo, &order.Operator, &order.InboundDate, &order.Handler, &order.Supplier, &order.Remark, &order.OrganizationID, &order.WarehouseID, &order.LocationID, &order.CreatedAt, &order.UpdatedAt) + if err != nil { + return nil, err + } + + order.Items, err = loadInboundItems(q, id) + if err != nil { + return nil, err } // 状态不落库:inbound_orders 没有 status 列,统一在这里给出。引入状态机时改为真实读写。 @@ -100,9 +125,20 @@ func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) { return order, nil } -func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrder, int64, error) { - countQuery := `SELECT COUNT(*) FROM inbound_orders WHERE 1=1` - var countArgs []interface{} +func (r *InboundRepo) GetByID(organizationID, id int64) (*model.InboundOrder, error) { + return r.getByID(database.DB, organizationID, id, false) +} + +// GetByIDForUpdateTx 在事务内锁定当前公司的原单据,别的公司的单据命中 ErrNoRows。 +func (r *InboundRepo) GetByIDForUpdateTx(tx *sql.Tx, organizationID, id int64) (*model.InboundOrder, error) { + return r.getByID(tx, organizationID, id, true) +} + +func (r *InboundRepo) List(organizationID int64, search string, limit, offset int) ([]model.InboundOrder, int64, error) { + // COUNT 和主查询是两条独立 SQL,组织条件与筛选条件必须成对出现,否则分页总数会和 + // 实际行数对不上。 + countQuery := `SELECT COUNT(*) FROM inbound_orders WHERE organization_id = ?` + countArgs := []interface{}{organizationID} if search != "" { countQuery += ` AND (handler LIKE ? OR supplier LIKE ?)` @@ -116,8 +152,8 @@ func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrd return nil, 0, err } - query := `SELECT id, COALESCE(inbound_no, CONCAT('IN-', LPAD(id, 8, '0'))), COALESCE(operator, ''), inbound_date, handler, supplier, COALESCE(remark, ''), created_at, updated_at FROM inbound_orders WHERE 1=1` - var args []interface{} + query := `SELECT id, COALESCE(inbound_no, CONCAT('IN-', LPAD(id, 8, '0'))), COALESCE(operator, ''), inbound_date, handler, supplier, COALESCE(remark, ''), organization_id, warehouse_id, location_id, created_at, updated_at FROM inbound_orders WHERE organization_id = ?` + args := []interface{}{organizationID} if search != "" { query += ` AND (handler LIKE ? OR supplier LIKE ?)` @@ -136,7 +172,7 @@ func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrd var orders []model.InboundOrder for rows.Next() { var order model.InboundOrder - err := rows.Scan(&order.ID, &order.InboundNo, &order.Operator, &order.InboundDate, &order.Handler, &order.Supplier, &order.Remark, &order.CreatedAt, &order.UpdatedAt) + err := rows.Scan(&order.ID, &order.InboundNo, &order.Operator, &order.InboundDate, &order.Handler, &order.Supplier, &order.Remark, &order.OrganizationID, &order.WarehouseID, &order.LocationID, &order.CreatedAt, &order.UpdatedAt) if err != nil { return nil, 0, err } @@ -147,14 +183,17 @@ func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrd return orders, total, nil } -func (r *InboundRepo) GetByMaterialID(materialID int64) ([]model.InboundRecord, error) { +// GetByMaterialID 返回某物料在本公司的入库历史。头表按组织过滤,明细 JOIN 的物料也 +// 必须属于同一公司,公司 A 不能凭物料 ID 读到公司 B 的入库记录。 +func (r *InboundRepo) GetByMaterialID(organizationID, materialID int64) ([]model.InboundRecord, error) { query := `SELECT ii.id, ii.inbound_order_id, io.handler, io.supplier, ii.quantity, ii.created_at FROM inbound_items ii JOIN inbound_orders io ON ii.inbound_order_id = io.id - WHERE ii.material_id = ? + JOIN materials m ON ii.material_id = m.id AND m.organization_id = io.organization_id + WHERE ii.material_id = ? AND io.organization_id = ? ORDER BY ii.id DESC` - rows, err := database.DB.Query(query, materialID) + rows, err := database.DB.Query(query, materialID, organizationID) if err != nil { return nil, err } @@ -172,18 +211,18 @@ func (r *InboundRepo) GetByMaterialID(materialID int64) ([]model.InboundRecord, return records, nil } -func (r *InboundRepo) GetInboundDate(id int64) (time.Time, error) { +func (r *InboundRepo) GetInboundDate(organizationID, id int64) (time.Time, error) { var inboundDate time.Time - query := `SELECT inbound_date FROM inbound_orders WHERE id = ?` - err := database.DB.QueryRow(query, id).Scan(&inboundDate) + query := `SELECT inbound_date FROM inbound_orders WHERE id = ? AND organization_id = ?` + err := database.DB.QueryRow(query, id, organizationID).Scan(&inboundDate) return inboundDate, err } -func (r *InboundRepo) Delete(id int64) error { - return r.DeleteTx(nil, id) +func (r *InboundRepo) Delete(organizationID, id int64) error { + return r.DeleteTx(nil, organizationID, id) } -func (r *InboundRepo) DeleteTx(tx *sql.Tx, id int64) error { +func (r *InboundRepo) DeleteTx(tx *sql.Tx, organizationID, id int64) error { if tx == nil { var err error tx, err = database.DB.Begin() @@ -191,21 +230,21 @@ func (r *InboundRepo) DeleteTx(tx *sql.Tx, id int64) error { return err } defer tx.Rollback() - _, err = tx.Exec(`DELETE FROM inbound_orders WHERE id = ?`, id) + _, err = tx.Exec(`DELETE FROM inbound_orders WHERE organization_id = ? AND id = ?`, organizationID, id) if err != nil { return err } return tx.Commit() } - _, err := tx.Exec(`DELETE FROM inbound_orders WHERE id = ?`, id) + _, err := tx.Exec(`DELETE FROM inbound_orders WHERE organization_id = ? AND id = ?`, organizationID, id) return err } -func (r *InboundRepo) Update(id int64, req *model.UpdateInboundRequest) error { - return r.UpdateTx(nil, id, req) +func (r *InboundRepo) Update(organizationID, id int64, req *model.UpdateInboundRequest) error { + return r.UpdateTx(nil, organizationID, id, req) } -func (r *InboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateInboundRequest) error { +func (r *InboundRepo) UpdateTx(tx *sql.Tx, organizationID, id int64, req *model.UpdateInboundRequest) error { shouldCommit := false if tx == nil { var err error @@ -219,9 +258,20 @@ func (r *InboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateInboundReq defer tx.Rollback() } - // Update order basic fields - query := `UPDATE inbound_orders SET handler = ?, supplier = ?, remark = ? WHERE id = ?` - _, err := tx.Exec(query, req.Handler, req.Supplier, req.Remark, id) + // 先确认目标单据属于本公司:跨公司的单据在这里直接返回 ErrNoRows,后面的明细 + // 删除/插入绝不会碰到别家公司的单据(明细表没有组织列,只能靠头表归属收口)。 + var existsID int64 + err := tx.QueryRow(`SELECT id FROM inbound_orders WHERE organization_id = ? AND id = ?`, organizationID, id).Scan(&existsID) + if err == sql.ErrNoRows { + return sql.ErrNoRows + } + if err != nil { + return err + } + + // Update order basic fields,写入带组织条件,跨公司的单据不会在这里被改动。 + query := `UPDATE inbound_orders SET handler = ?, supplier = ?, remark = ? WHERE organization_id = ? AND id = ?` + _, err = tx.Exec(query, req.Handler, req.Supplier, req.Remark, organizationID, id) if err != nil { return err } diff --git a/internal/repository/inbound_organization_test.go b/internal/repository/inbound_organization_test.go new file mode 100644 index 00000000..cc5cd617 --- /dev/null +++ b/internal/repository/inbound_organization_test.go @@ -0,0 +1,175 @@ +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) + } +} diff --git a/internal/repository/order_null_fields_test.go b/internal/repository/order_null_fields_test.go index 446f8b17..56712014 100644 --- a/internal/repository/order_null_fields_test.go +++ b/internal/repository/order_null_fields_test.go @@ -20,16 +20,17 @@ func TestInboundListConvertsLegacyNullDisplayFields(t *testing.T) { }) database.DB = db - mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM inbound_orders WHERE 1=1`)). + mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM inbound_orders WHERE organization_id = ?`)). + WithArgs(int64(1)). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) - query := `SELECT id, COALESCE(inbound_no, CONCAT('IN-', LPAD(id, 8, '0'))), COALESCE(operator, ''), inbound_date, handler, supplier, COALESCE(remark, ''), created_at, updated_at FROM inbound_orders WHERE 1=1 ORDER BY id DESC LIMIT ? OFFSET ?` + query := `SELECT id, COALESCE(inbound_no, CONCAT('IN-', LPAD(id, 8, '0'))), COALESCE(operator, ''), inbound_date, handler, supplier, COALESCE(remark, ''), organization_id, warehouse_id, location_id, created_at, updated_at FROM inbound_orders WHERE organization_id = ? ORDER BY id DESC LIMIT ? OFFSET ?` now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) - mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(20, 0).WillReturnRows( - sqlmock.NewRows([]string{"id", "inbound_no", "operator", "inbound_date", "handler", "supplier", "remark", "created_at", "updated_at"}). - AddRow(42, "IN-00000042", "", now, "张三", "供应商", "", now, now), + mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(1), 20, 0).WillReturnRows( + sqlmock.NewRows([]string{"id", "inbound_no", "operator", "inbound_date", "handler", "supplier", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"}). + AddRow(42, "IN-00000042", "", now, "张三", "供应商", "", 1, 10, 20, now, now), ) - orders, _, err := NewInboundRepo().List("", 20, 0) + orders, _, err := NewInboundRepo().List(1, "", 20, 0) if err != nil { t.Fatalf("list inbound orders with legacy NULL fields: %v", err) } @@ -52,16 +53,17 @@ func TestOutboundListConvertsLegacyNullDisplayFields(t *testing.T) { }) database.DB = db - mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM outbound_orders WHERE 1=1`)). + mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM outbound_orders WHERE organization_id = ?`)). + WithArgs(int64(1)). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) - query := `SELECT id, COALESCE(outbound_no, CONCAT('OUT-', LPAD(id, 8, '0'))), COALESCE(operator, ''), outbound_date, handler, receiver, COALESCE(remark, ''), created_at, updated_at FROM outbound_orders WHERE 1=1 ORDER BY id DESC LIMIT ? OFFSET ?` + query := `SELECT id, COALESCE(outbound_no, CONCAT('OUT-', LPAD(id, 8, '0'))), COALESCE(operator, ''), outbound_date, handler, receiver, COALESCE(remark, ''), organization_id, warehouse_id, location_id, created_at, updated_at FROM outbound_orders WHERE organization_id = ? ORDER BY id DESC LIMIT ? OFFSET ?` now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) - mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(20, 0).WillReturnRows( - sqlmock.NewRows([]string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "created_at", "updated_at"}). - AddRow(27, "OUT-00000027", "", now, "李四", "装配车间", "", now, now), + mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(1), 20, 0).WillReturnRows( + sqlmock.NewRows([]string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"}). + AddRow(27, "OUT-00000027", "", now, "李四", "装配车间", "", 1, 10, 20, now, now), ) - orders, _, err := NewOutboundRepo().List("", 20, 0) + orders, _, err := NewOutboundRepo().List(1, "", 20, 0) if err != nil { t.Fatalf("list outbound orders with legacy NULL fields: %v", err) } diff --git a/internal/repository/outbound.go b/internal/repository/outbound.go index e99bcc5e..9f249d4a 100644 --- a/internal/repository/outbound.go +++ b/internal/repository/outbound.go @@ -32,8 +32,10 @@ func (r *OutboundRepo) CreateTx(tx *sql.Tx, order *model.OutboundOrder) error { defer tx.Rollback() } - query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, remark) VALUES (?, ?, ?, ?, ?)` - result, err := tx.Exec(query, order.OutboundDate, order.Operator, order.Handler, order.Receiver, order.Remark) + // 三个库存维度由服务端解析后显式落库;表上这三列 NOT NULL 无默认值,缺一即报错, + // 不会静默写进默认公司。 + query := `INSERT INTO outbound_orders (organization_id, warehouse_id, location_id, outbound_date, operator, handler, receiver, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + result, err := tx.Exec(query, order.OrganizationID, order.WarehouseID, order.LocationID, order.OutboundDate, order.Operator, order.Handler, order.Receiver, order.Remark) if err != nil { return err } @@ -68,31 +70,47 @@ func (r *OutboundRepo) CreateTx(tx *sql.Tx, order *model.OutboundOrder) error { return nil } -func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) { - order := &model.OutboundOrder{} - query := `SELECT id, COALESCE(outbound_no, CONCAT('OUT-', LPAD(id, 8, '0'))), COALESCE(operator, ''), outbound_date, handler, receiver, COALESCE(remark, ''), created_at, updated_at FROM outbound_orders WHERE id = ?` - err := database.DB.QueryRow(query, id).Scan(&order.ID, &order.OutboundNo, &order.Operator, &order.OutboundDate, &order.Handler, &order.Receiver, &order.Remark, &order.CreatedAt, &order.UpdatedAt) - if err != nil { - return nil, err - } - +// loadOutboundItems 读取单据明细,并 JOIN 头表与物料表把明细限制在同一组织内: +// 头表已按组织过滤,明细物料也必须属于同一公司,避免读到跨公司的物料行。 +func loadOutboundItems(q orderQuerier, orderID int64) ([]model.OutboundItem, error) { itemsQuery := `SELECT oi.id, oi.outbound_order_id, oi.material_id, oi.quantity, m.material_code, m.name, m.status FROM outbound_items oi - JOIN materials m ON oi.material_id = m.id + JOIN outbound_orders oo ON oo.id = oi.outbound_order_id + JOIN materials m ON m.id = oi.material_id AND m.organization_id = oo.organization_id WHERE oi.outbound_order_id = ?` - rows, err := database.DB.Query(itemsQuery, id) + rows, err := q.Query(itemsQuery, orderID) if err != nil { return nil, err } defer rows.Close() + var items []model.OutboundItem for rows.Next() { var item model.OutboundItem - err := rows.Scan(&item.ID, &item.OutboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus) - if err != nil { + if err := rows.Scan(&item.ID, &item.OutboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus); err != nil { return nil, err } - order.Items = append(order.Items, item) + items = append(items, item) + } + return items, rows.Err() +} + +// getByID 按公司读取单据头表加明细。lock=true 时用 FOR UPDATE 锁定当前公司的原单据, +// 供更新/删除事务在同一把锁下冲销原库存。别的公司的单据在这里命中 sql.ErrNoRows。 +func (r *OutboundRepo) getByID(q orderQuerier, organizationID, id int64, lock bool) (*model.OutboundOrder, error) { + order := &model.OutboundOrder{} + query := `SELECT id, COALESCE(outbound_no, CONCAT('OUT-', LPAD(id, 8, '0'))), COALESCE(operator, ''), outbound_date, handler, receiver, COALESCE(remark, ''), organization_id, warehouse_id, location_id, created_at, updated_at FROM outbound_orders WHERE id = ? AND organization_id = ?` + if lock { + query += ` FOR UPDATE` + } + err := q.QueryRow(query, id, organizationID).Scan(&order.ID, &order.OutboundNo, &order.Operator, &order.OutboundDate, &order.Handler, &order.Receiver, &order.Remark, &order.OrganizationID, &order.WarehouseID, &order.LocationID, &order.CreatedAt, &order.UpdatedAt) + if err != nil { + return nil, err + } + + order.Items, err = loadOutboundItems(q, id) + if err != nil { + return nil, err } // 状态不落库:outbound_orders 没有 status 列,统一在这里给出。引入状态机时改为真实读写。 @@ -100,9 +118,20 @@ func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) { return order, nil } -func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundOrder, int64, error) { - countQuery := `SELECT COUNT(*) FROM outbound_orders WHERE 1=1` - var countArgs []interface{} +func (r *OutboundRepo) GetByID(organizationID, id int64) (*model.OutboundOrder, error) { + return r.getByID(database.DB, organizationID, id, false) +} + +// GetByIDForUpdateTx 在事务内锁定当前公司的原单据,别的公司的单据命中 ErrNoRows。 +func (r *OutboundRepo) GetByIDForUpdateTx(tx *sql.Tx, organizationID, id int64) (*model.OutboundOrder, error) { + return r.getByID(tx, organizationID, id, true) +} + +func (r *OutboundRepo) List(organizationID int64, search string, limit, offset int) ([]model.OutboundOrder, int64, error) { + // COUNT 和主查询是两条独立 SQL,组织条件与筛选条件必须成对出现,否则分页总数会和 + // 实际行数对不上。 + countQuery := `SELECT COUNT(*) FROM outbound_orders WHERE organization_id = ?` + countArgs := []interface{}{organizationID} if search != "" { countQuery += ` AND (handler LIKE ? OR receiver LIKE ?)` @@ -116,8 +145,8 @@ func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundO return nil, 0, err } - query := `SELECT id, COALESCE(outbound_no, CONCAT('OUT-', LPAD(id, 8, '0'))), COALESCE(operator, ''), outbound_date, handler, receiver, COALESCE(remark, ''), created_at, updated_at FROM outbound_orders WHERE 1=1` - var args []interface{} + query := `SELECT id, COALESCE(outbound_no, CONCAT('OUT-', LPAD(id, 8, '0'))), COALESCE(operator, ''), outbound_date, handler, receiver, COALESCE(remark, ''), organization_id, warehouse_id, location_id, created_at, updated_at FROM outbound_orders WHERE organization_id = ?` + args := []interface{}{organizationID} if search != "" { query += ` AND (handler LIKE ? OR receiver LIKE ?)` @@ -136,7 +165,7 @@ func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundO var orders []model.OutboundOrder for rows.Next() { var order model.OutboundOrder - err := rows.Scan(&order.ID, &order.OutboundNo, &order.Operator, &order.OutboundDate, &order.Handler, &order.Receiver, &order.Remark, &order.CreatedAt, &order.UpdatedAt) + err := rows.Scan(&order.ID, &order.OutboundNo, &order.Operator, &order.OutboundDate, &order.Handler, &order.Receiver, &order.Remark, &order.OrganizationID, &order.WarehouseID, &order.LocationID, &order.CreatedAt, &order.UpdatedAt) if err != nil { return nil, 0, err } @@ -147,14 +176,17 @@ func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundO return orders, total, nil } -func (r *OutboundRepo) GetByMaterialID(materialID int64) ([]model.OutboundRecord, error) { +// GetByMaterialID 返回某物料在本公司的出库历史。头表按组织过滤,明细 JOIN 的物料也 +// 必须属于同一公司,公司 A 不能凭物料 ID 读到公司 B 的出库记录。 +func (r *OutboundRepo) GetByMaterialID(organizationID, materialID int64) ([]model.OutboundRecord, error) { query := `SELECT oi.id, oi.outbound_order_id, oo.handler, oo.receiver, oi.quantity, oi.created_at FROM outbound_items oi JOIN outbound_orders oo ON oi.outbound_order_id = oo.id - WHERE oi.material_id = ? + JOIN materials m ON oi.material_id = m.id AND m.organization_id = oo.organization_id + WHERE oi.material_id = ? AND oo.organization_id = ? ORDER BY oi.id DESC` - rows, err := database.DB.Query(query, materialID) + rows, err := database.DB.Query(query, materialID, organizationID) if err != nil { return nil, err } @@ -172,18 +204,18 @@ func (r *OutboundRepo) GetByMaterialID(materialID int64) ([]model.OutboundRecord return records, nil } -func (r *OutboundRepo) GetOutboundDate(id int64) (time.Time, error) { +func (r *OutboundRepo) GetOutboundDate(organizationID, id int64) (time.Time, error) { var outboundDate time.Time - query := `SELECT outbound_date FROM outbound_orders WHERE id = ?` - err := database.DB.QueryRow(query, id).Scan(&outboundDate) + query := `SELECT outbound_date FROM outbound_orders WHERE id = ? AND organization_id = ?` + err := database.DB.QueryRow(query, id, organizationID).Scan(&outboundDate) return outboundDate, err } -func (r *OutboundRepo) Delete(id int64) error { - return r.DeleteTx(nil, id) +func (r *OutboundRepo) Delete(organizationID, id int64) error { + return r.DeleteTx(nil, organizationID, id) } -func (r *OutboundRepo) DeleteTx(tx *sql.Tx, id int64) error { +func (r *OutboundRepo) DeleteTx(tx *sql.Tx, organizationID, id int64) error { if tx == nil { var err error tx, err = database.DB.Begin() @@ -191,21 +223,21 @@ func (r *OutboundRepo) DeleteTx(tx *sql.Tx, id int64) error { return err } defer tx.Rollback() - _, err = tx.Exec(`DELETE FROM outbound_orders WHERE id = ?`, id) + _, err = tx.Exec(`DELETE FROM outbound_orders WHERE organization_id = ? AND id = ?`, organizationID, id) if err != nil { return err } return tx.Commit() } - _, err := tx.Exec(`DELETE FROM outbound_orders WHERE id = ?`, id) + _, err := tx.Exec(`DELETE FROM outbound_orders WHERE organization_id = ? AND id = ?`, organizationID, id) return err } -func (r *OutboundRepo) Update(id int64, req *model.UpdateOutboundRequest) error { - return r.UpdateTx(nil, id, req) +func (r *OutboundRepo) Update(organizationID, id int64, req *model.UpdateOutboundRequest) error { + return r.UpdateTx(nil, organizationID, id, req) } -func (r *OutboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateOutboundRequest) error { +func (r *OutboundRepo) UpdateTx(tx *sql.Tx, organizationID, id int64, req *model.UpdateOutboundRequest) error { shouldCommit := false if tx == nil { var err error @@ -219,9 +251,20 @@ func (r *OutboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateOutboundR defer tx.Rollback() } - // Update order basic fields - query := `UPDATE outbound_orders SET operator = ?, handler = ?, receiver = ?, remark = ? WHERE id = ?` - _, err := tx.Exec(query, req.Operator, req.Handler, req.Receiver, req.Remark, id) + // 先确认目标单据属于本公司:跨公司的单据在这里直接返回 ErrNoRows,后面的明细 + // 删除/插入绝不会碰到别家公司的单据(明细表没有组织列,只能靠头表归属收口)。 + var existsID int64 + err := tx.QueryRow(`SELECT id FROM outbound_orders WHERE organization_id = ? AND id = ?`, organizationID, id).Scan(&existsID) + if err == sql.ErrNoRows { + return sql.ErrNoRows + } + if err != nil { + return err + } + + // Update order basic fields,写入带组织条件,跨公司的单据不会在这里被改动。 + query := `UPDATE outbound_orders SET operator = ?, handler = ?, receiver = ?, remark = ? WHERE organization_id = ? AND id = ?` + _, err = tx.Exec(query, req.Operator, req.Handler, req.Receiver, req.Remark, organizationID, id) if err != nil { return err } diff --git a/internal/repository/outbound_organization_test.go b/internal/repository/outbound_organization_test.go new file mode 100644 index 00000000..e459f72c --- /dev/null +++ b/internal/repository/outbound_organization_test.go @@ -0,0 +1,119 @@ +package repository + +import ( + "database/sql" + "testing" + "time" + + "github.com/wmms/wmms/internal/model" +) + +func TestOutboundRepoOrganizationIsolationReads(t *testing.T) { + db := isolationDB(t, false) + seedOrderIsolation(t, db) + repo := NewOutboundRepo() + + ownOrder, err := repo.GetByID(102, 102) + if err != nil || ownOrder.ID != 102 || ownOrder.OrganizationID != 102 { + t.Fatalf("B 读自己的单据失败: %+v err=%v", ownOrder, err) + } + if _, err := repo.GetByID(102, 101); err != sql.ErrNoRows { + t.Fatalf("B 读 A 的单据必须 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) + } + + recordsB, err := repo.GetByMaterialID(102, 102) + if err != nil || len(recordsB) != 1 { + t.Fatalf("B 物料历史=%+v err=%v", recordsB, err) + } + recordsCross, err := repo.GetByMaterialID(102, 101) + if err != nil || len(recordsCross) != 0 { + t.Fatalf("B 读 A 物料历史必须为空, got %+v err=%v", recordsCross, err) + } +} + +func TestOutboundRepoCreatePersistsDimensionsAndRestrictsItems(t *testing.T) { + db := isolationDB(t, false) + seedOrderIsolation(t, db) + repo := NewOutboundRepo() + + order := &model.OutboundOrder{ + OrganizationID: 102, + WarehouseID: 102, + LocationID: 102, + OutboundDate: time.Now(), + Operator: "op", + Handler: "h", + Receiver: "r", + Items: []model.OutboundItem{{MaterialID: 102, 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 outbound_orders WHERE id = ?`, order.ID).Scan(&org, &warehouse, &location); err != nil { + t.Fatal(err) + } + if org != 102 || warehouse != 102 || location != 102 { + t.Fatalf("头表维度=%d/%d/%d, want 102/102/102", org, warehouse, location) + } + + // 塞一条 A 的物料明细:物料外键合法,但读取时的同组织关联会把它挡掉。 + mustSQL(t, db, `INSERT INTO outbound_items(outbound_order_id, material_id, quantity) VALUES (?, ?, ?)`, order.ID, 101, 9) + loaded, err := repo.GetByID(102, order.ID) + if err != nil { + t.Fatal(err) + } + if len(loaded.Items) != 1 || loaded.Items[0].MaterialID != 102 { + t.Fatalf("跨公司物料明细必须被关联条件挡掉, got %+v", loaded.Items) + } +} + +func TestOutboundRepoUpdateAndDeleteScopeToOrganization(t *testing.T) { + db := isolationDB(t, false) + seedOrderIsolation(t, db) + repo := NewOutboundRepo() + + if err := repo.Update(102, 101, &model.UpdateOutboundRequest{Handler: "hacked", Receiver: "hacked"}); err != sql.ErrNoRows { + t.Fatalf("B 改 A 的单据必须 ErrNoRows, got %v", err) + } + var handler, receiver string + if err := db.QueryRow(`SELECT handler, receiver FROM outbound_orders WHERE id = 101`).Scan(&handler, &receiver); err != nil { + t.Fatal(err) + } + if handler != "A-out" || receiver != "recA" { + t.Fatalf("A 的单据头被改动: %s/%s", handler, receiver) + } + var itemCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM outbound_items WHERE outbound_order_id = 101`).Scan(&itemCount); err != nil { + t.Fatal(err) + } + if itemCount != 1 { + t.Fatalf("A 的明细被改动: %d 行", itemCount) + } + + if err := repo.Delete(102, 101); err != nil { + t.Fatal(err) + } + var orderCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM outbound_orders WHERE id = 101`).Scan(&orderCount); err != nil { + t.Fatal(err) + } + if orderCount != 1 { + t.Fatal("B 删掉了 A 的单据") + } +} diff --git a/internal/repository/stock.go b/internal/repository/stock.go index 32603fe7..fbc72638 100644 --- a/internal/repository/stock.go +++ b/internal/repository/stock.go @@ -448,9 +448,11 @@ func (r *StockRepo) UpdateWarningStockTx(tx *sql.Tx, scope model.InventoryScope, return upsertStockWarningRule(tx, scope, materialID, minimumAvailable, enabled) } -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) +// DeleteLogs 删除指定公司的库存流水:组织、关联类型、单据 ID 三个条件同时约束, +// 否则凭别家公司的单据 ID 就能删掉它的流水。 +func (r *StockRepo) DeleteLogs(organizationID int64, orderType string, orderID int64) error { + query := `DELETE FROM stock_logs WHERE organization_id = ? AND related_order_type = ? AND related_order_id = ?` + _, err := database.DB.Exec(query, organizationID, orderType, orderID) return err } @@ -468,10 +470,10 @@ func (r *StockRepo) CreateLogTx(tx *sql.Tx, log *model.StockLog) error { 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) +// DeleteLogsTx 在事务内删除指定公司的库存流水,同样同时约束组织、关联类型和单据 ID。 +func (r *StockRepo) DeleteLogsTx(tx *sql.Tx, organizationID int64, orderType string, orderID int64) error { + query := `DELETE FROM stock_logs WHERE organization_id = ? AND related_order_type = ? AND related_order_id = ?` + _, err := tx.Exec(query, organizationID, orderType, orderID) return err } diff --git a/internal/service/inbound.go b/internal/service/inbound.go index 0f067a38..41655c6e 100644 --- a/internal/service/inbound.go +++ b/internal/service/inbound.go @@ -72,6 +72,10 @@ func (s *InboundService) Create(organizationID int64, req *model.CreateInboundRe if err != nil { return nil, err } + // 单据归属只取服务端解析出的当前公司维度,请求体里伪造的归属一律被覆盖。 + order.OrganizationID = scope.OrganizationID + order.WarehouseID = scope.WarehouseID + order.LocationID = scope.LocationID // Start transaction tx, err := repository.BeginTx() @@ -143,31 +147,19 @@ func (s *InboundService) Create(organizationID int64, req *model.CreateInboundRe return order, nil } -func (s *InboundService) GetByID(id int64) (*model.InboundOrder, error) { - return s.inboundRepo.GetByID(id) +func (s *InboundService) GetByID(organizationID, id int64) (*model.InboundOrder, error) { + return s.inboundRepo.GetByID(organizationID, id) } -func (s *InboundService) List(search string, limit, offset int) ([]model.InboundOrder, int64, error) { - return s.inboundRepo.List(search, limit, offset) +func (s *InboundService) List(organizationID int64, search string, limit, offset int) ([]model.InboundOrder, int64, error) { + return s.inboundRepo.List(organizationID, search, limit, offset) } -func (s *InboundService) GetByMaterialID(materialID int64) ([]model.InboundRecord, error) { - return s.inboundRepo.GetByMaterialID(materialID) +func (s *InboundService) GetByMaterialID(organizationID, materialID int64) ([]model.InboundRecord, error) { + return s.inboundRepo.GetByMaterialID(organizationID, materialID) } func (s *InboundService) Delete(organizationID, id int64) error { - // Get the inbound order with items - order, err := s.inboundRepo.GetByID(id) - if err != nil { - return err - } - - // 冲销必须在当前公司启用的库存维度上进行。 - scope, err := ResolveInventoryScope(organizationID, true) - if err != nil { - return err - } - // Start transaction tx, err := repository.BeginTx() if err != nil { @@ -179,6 +171,19 @@ func (s *InboundService) Delete(organizationID, id int64) error { } }() + // 在事务中锁定当前公司的原单据;别的公司的单据在这里命中 ErrNoRows(Handler 映射 404), + // 不会去冲销别家公司的库存。 + order, err := s.inboundRepo.GetByIDForUpdateTx(tx, organizationID, id) + if err != nil { + return err + } + + // 冲销必须在当前公司启用的库存维度上进行。 + scope, err := ResolveInventoryScope(organizationID, true) + if err != nil { + return err + } + // Reverse stock for each item within transaction for _, item := range order.Items { err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(-item.Quantity))) @@ -187,14 +192,14 @@ func (s *InboundService) Delete(organizationID, id int64) error { } } - // Delete stock logs within transaction - err = s.stockRepo.DeleteLogsTx(tx, "inbound", id) + // Delete stock logs within transaction(只删本公司的流水) + err = s.stockRepo.DeleteLogsTx(tx, organizationID, "inbound", id) if err != nil { return err } // Delete the inbound order (items will be cascade deleted) - err = s.inboundRepo.DeleteTx(tx, id) + err = s.inboundRepo.DeleteTx(tx, organizationID, id) if err != nil { return err } @@ -208,8 +213,8 @@ func (s *InboundService) Delete(organizationID, id int64) error { } func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInboundRequest) (*model.InboundOrder, error) { - // Get old order data - oldOrder, err := s.inboundRepo.GetByID(id) + // Get old order data(带组织条件,别家公司的单据在这里就命中 ErrNoRows) + oldOrder, err := s.inboundRepo.GetByID(organizationID, id) if err != nil { return nil, err } @@ -245,14 +250,20 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou } }() + // 在事务中锁定当前公司的原单据,并以锁下的原明细为准冲销;锁只加在本公司单据上。 + oldOrder, err = s.inboundRepo.GetByIDForUpdateTx(tx, organizationID, id) + if err != nil { + return nil, err + } + // Delete old stock logs - err = s.stockRepo.DeleteLogsTx(tx, "inbound", id) + err = s.stockRepo.DeleteLogsTx(tx, organizationID, "inbound", id) if err != nil { return nil, err } // Update order in repository - err = s.inboundRepo.UpdateTx(tx, id, req) + err = s.inboundRepo.UpdateTx(tx, organizationID, id, req) if err != nil { return nil, err } @@ -331,7 +342,7 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou } // Get updated order with material details - updatedOrder, err := s.inboundRepo.GetByID(id) + updatedOrder, err := s.inboundRepo.GetByID(organizationID, id) if err != nil { return nil, err } diff --git a/internal/service/inbound_test.go b/internal/service/inbound_test.go index 6db68993..d9fe0e90 100644 --- a/internal/service/inbound_test.go +++ b/internal/service/inbound_test.go @@ -1,6 +1,76 @@ package service -import "testing" +import ( + "database/sql" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/shopspring/decimal" + "github.com/wmms/wmms/internal/model" +) + +func createInboundRequest() *model.CreateInboundRequest { + req := &model.CreateInboundRequest{Handler: "h", Supplier: "s", Remark: "r"} + req.Items = append(req.Items, struct { + MaterialID int64 `json:"material_id" binding:"required"` + Quantity int `json:"quantity" binding:"required,gt=0"` + WarningStock *decimal.Decimal `json:"warning_stock"` + WarningEnabled *bool `json:"warning_enabled"` + }{MaterialID: 7, Quantity: 2}) + return req +} + +// 入库单据的三个维度由服务端按当前公司解析后填写:请求体没有任何归属字段, +// 后端把 scope 的公司、仓库、库位写进单据头,落库 SQL 也带上这三列。 +func TestInboundCreateFillsOrderDimensionsFromResolvedScope(t *testing.T) { + mock := outboundMock(t) + // Create 先校验明细物料,再解析库存维度。 + expectActiveMaterial(mock, 7) + expectStockScope(mock, 1) + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO inbound_orders"). + WithArgs(int64(1), int64(10), int64(20), sqlmock.AnyArg(), "operator", "h", "s", "r"). + WillReturnResult(sqlmock.NewResult(5, 1)) + mock.ExpectExec("UPDATE inbound_orders").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO inbound_items").WithArgs(int64(5), int64(7), 2).WillReturnResult(sqlmock.NewResult(6, 1)) + // 明细补物料编码/名称时再读一次物料。 + mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7)) + mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "in", 2, "inbound", int64(5)).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + order, err := NewInboundService().Create(1, createInboundRequest(), "operator") + if err != nil { + t.Fatalf("create inbound: %v", err) + } + if order.OrganizationID != 1 || order.WarehouseID != 10 || order.LocationID != 20 { + t.Fatalf("单据维度=%d/%d/%d, want 1/10/20", order.OrganizationID, order.WarehouseID, order.LocationID) + } + if order.InboundNo != "IN-00000005" { + t.Fatalf("单号=%s, want IN-00000005", order.InboundNo) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// 删除别家公司的单据:事务内锁定当前公司原单据时命中 ErrNoRows,整体不冲销任何库存, +// 也不会删掉别家公司的单据。 +func TestInboundDeleteCrossOrganizationReturnsNoRows(t *testing.T) { + mock := outboundMock(t) + mock.ExpectBegin() + mock.ExpectQuery("SELECT id, COALESCE\\(inbound_no").WithArgs(int64(9), int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectRollback() + + err := NewInboundService().Delete(1, 9) + if err != sql.ErrNoRows { + t.Fatalf("got %v, want sql.ErrNoRows", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} // resolveWarningEnabled 决定入库时写预警规则要不要启用。老客户端只会传 warning_stock、 // 从不传 warning_enabled,那种情况下必须保持原来的"带上阈值即启用"行为。 diff --git a/internal/service/outbound.go b/internal/service/outbound.go index 66c05583..fff510ba 100644 --- a/internal/service/outbound.go +++ b/internal/service/outbound.go @@ -42,6 +42,10 @@ func (s *OutboundService) Create(organizationID int64, req *model.CreateOutbound if err != nil { return nil, err } + // 单据归属只取服务端解析出的当前公司维度,请求体里伪造的归属一律被覆盖。 + order.OrganizationID = scope.OrganizationID + order.WarehouseID = scope.WarehouseID + order.LocationID = scope.LocationID // Validate all items first (outside transaction) for i, item := range req.Items { @@ -153,21 +157,29 @@ func materialSortedIndexes(materialIDs []int64) []int { return indexes } -func (s *OutboundService) GetByID(id int64) (*model.OutboundOrder, error) { - return s.outboundRepo.GetByID(id) +func (s *OutboundService) GetByID(organizationID, id int64) (*model.OutboundOrder, error) { + return s.outboundRepo.GetByID(organizationID, id) } -func (s *OutboundService) List(search string, limit, offset int) ([]model.OutboundOrder, int64, error) { - return s.outboundRepo.List(search, limit, offset) +func (s *OutboundService) List(organizationID int64, search string, limit, offset int) ([]model.OutboundOrder, int64, error) { + return s.outboundRepo.List(organizationID, search, limit, offset) } -func (s *OutboundService) GetByMaterialID(materialID int64) ([]model.OutboundRecord, error) { - return s.outboundRepo.GetByMaterialID(materialID) +func (s *OutboundService) GetByMaterialID(organizationID, materialID int64) ([]model.OutboundRecord, error) { + return s.outboundRepo.GetByMaterialID(organizationID, materialID) } func (s *OutboundService) Delete(organizationID, id int64) error { - // Get the outbound order with items - order, err := s.outboundRepo.GetByID(id) + // Start transaction + tx, err := repository.BeginTx() + if err != nil { + return err + } + defer tx.Rollback() + + // 在事务中锁定当前公司的原单据;别的公司的单据在这里命中 ErrNoRows(Handler 映射 404), + // 不会去冲销别家公司的库存。 + order, err := s.outboundRepo.GetByIDForUpdateTx(tx, organizationID, id) if err != nil { return err } @@ -178,13 +190,6 @@ func (s *OutboundService) Delete(organizationID, id int64) error { return err } - // Start transaction - tx, err := repository.BeginTx() - if err != nil { - return err - } - defer tx.Rollback() - // 按物料 ID 升序把库存加回,固定加锁顺序。 itemIDs := make([]int64, len(order.Items)) for i := range order.Items { @@ -198,14 +203,14 @@ func (s *OutboundService) Delete(organizationID, id int64) error { } } - // Delete stock logs within transaction - err = s.stockRepo.DeleteLogsTx(tx, "outbound", id) + // Delete stock logs within transaction(只删本公司的流水) + err = s.stockRepo.DeleteLogsTx(tx, organizationID, "outbound", id) if err != nil { return err } // Delete the outbound order (items will be cascade deleted) - err = s.outboundRepo.DeleteTx(tx, id) + err = s.outboundRepo.DeleteTx(tx, organizationID, id) if err != nil { return err } @@ -219,8 +224,8 @@ func (s *OutboundService) Delete(organizationID, id int64) error { } func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutboundRequest, operator string) (*model.OutboundOrder, error) { - // Get old order data - oldOrder, err := s.outboundRepo.GetByID(id) + // Get old order data(带组织条件,别家公司的单据在这里就命中 ErrNoRows) + oldOrder, err := s.outboundRepo.GetByID(organizationID, id) if err != nil { return nil, err } @@ -273,8 +278,14 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb } defer tx.Rollback() + // 在事务中锁定当前公司的原单据,并以锁下的原明细为准冲销;锁只加在本公司单据上。 + oldOrder, err = s.outboundRepo.GetByIDForUpdateTx(tx, organizationID, id) + if err != nil { + return nil, err + } + // Delete old stock logs - err = s.stockRepo.DeleteLogsTx(tx, "outbound", id) + err = s.stockRepo.DeleteLogsTx(tx, organizationID, "outbound", id) if err != nil { return nil, err } @@ -283,7 +294,7 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb // 客户端不能指定操作人;编辑也由当前登录用户接管操作人和兼容字段 handler。 req.Handler = operator req.Operator = operator - err = s.outboundRepo.UpdateTx(tx, id, req) + err = s.outboundRepo.UpdateTx(tx, organizationID, id, req) if err != nil { return nil, err } @@ -348,7 +359,7 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb } // Get updated order with material details - updatedOrder, err := s.outboundRepo.GetByID(id) + updatedOrder, err := s.outboundRepo.GetByID(organizationID, id) if err != nil { return nil, err } diff --git a/internal/service/outbound_test.go b/internal/service/outbound_test.go index d2bbd33c..b1df478a 100644 --- a/internal/service/outbound_test.go +++ b/internal/service/outbound_test.go @@ -1,6 +1,8 @@ package service import ( + "database/sql" + "database/sql/driver" "errors" "testing" "time" @@ -22,11 +24,16 @@ func outboundMock(t *testing.T) sqlmock.Sqlmock { return mock } +// activeMaterialRow 造一行启用物料,供物料归属/存在性校验与明细补名使用。 +func activeMaterialRow(id int64) *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}). + AddRow(id, 1, "M-1", "Material", nil, "", nil, 1, 1, "pc", "active", 1, time.Now(), time.Now()) +} + func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) { for i := 0; i < 2; i++ { mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), id). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}). - AddRow(id, 1, "M-1", "Material", nil, "", nil, 1, 1, "pc", "active", 1, time.Now(), time.Now())) + WillReturnRows(activeMaterialRow(id)) } } @@ -103,16 +110,28 @@ func TestOutboundCreateRollsBackWhenMaterialReadFailsAfterOrderInsert(t *testing func TestOutboundUpdateDoesNotCountRestoredStockTwice(t *testing.T) { mock := outboundMock(t) now := time.Now() - mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11)).WillReturnRows( - sqlmock.NewRows([]string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "created_at", "updated_at"}).AddRow(11, "OUT-11", "operator", now, "operator", "receiver", "", now, now)) + headColumns := []string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"} + headRow := []driver.Value{11, "OUT-11", "operator", now, "operator", "receiver", "", 1, 10, 20, now, now} + itemColumns := []string{"id", "outbound_order_id", "material_id", "quantity", "material_code", "name", "status"} + itemRow := []driver.Value{12, 11, 7, 2, "M-1", "Material", "active"} + // 事务外先按公司读取原单据(带组织参数)。 + mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11), int64(1)).WillReturnRows( + sqlmock.NewRows(headColumns).AddRow(headRow...)) mock.ExpectQuery("SELECT oi.id, oi.outbound_order_id").WithArgs(int64(11)).WillReturnRows( - sqlmock.NewRows([]string{"id", "outbound_order_id", "material_id", "quantity", "material_code", "name", "status"}).AddRow(12, 11, 7, 2, "M-1", "Material", "active")) + sqlmock.NewRows(itemColumns).AddRow(itemRow...)) expectStockScope(mock, 1) expectActiveMaterial(mock, 7) // 预检:当前 2 + 旧单已扣 2 = 4,够新单的 4。 mock.ExpectQuery("SELECT COALESCE\\(SUM\\(on_hand\\), 0\\) FROM inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2")) mock.ExpectBegin() - mock.ExpectExec("DELETE FROM stock_logs").WillReturnResult(sqlmock.NewResult(0, 1)) + // 事务内锁定当前公司的原单据(FOR UPDATE),再以锁下的原明细冲销。 + mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11), int64(1)).WillReturnRows( + sqlmock.NewRows(headColumns).AddRow(headRow...)) + mock.ExpectQuery("SELECT oi.id, oi.outbound_order_id").WithArgs(int64(11)).WillReturnRows( + sqlmock.NewRows(itemColumns).AddRow(itemRow...)) + mock.ExpectExec("DELETE FROM stock_logs").WithArgs(int64(1), "outbound", int64(11)).WillReturnResult(sqlmock.NewResult(0, 1)) + // UpdateTx 先按公司确认单据归属,再改头表与明细。 + mock.ExpectQuery("SELECT id FROM outbound_orders").WithArgs(int64(1), int64(11)).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(11)) mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("DELETE FROM outbound_items").WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(13, 1)) @@ -149,3 +168,50 @@ func TestOutboundCreateRejectsWhenInventoryScopeUnavailable(t *testing.T) { t.Fatal(err) } } + +// 出库单据的三个维度由服务端按当前公司解析后填写,落库 SQL 带上公司、仓库、库位。 +func TestOutboundCreateFillsOrderDimensionsFromResolvedScope(t *testing.T) { + mock := outboundMock(t) + // Create 先解析库存维度,再校验明细并预检余额。 + expectStockScope(mock, 1) + expectActiveMaterial(mock, 7) + mock.ExpectQuery("SELECT COALESCE\\(SUM\\(on_hand\\), 0\\) FROM inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("5")) + mock.ExpectBegin() + mock.ExpectExec("UPDATE inventory_balances SET on_hand = on_hand").WithArgs("2", int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO outbound_orders"). + WithArgs(int64(1), int64(10), int64(20), sqlmock.AnyArg(), "operator", "operator", "receiver", ""). + WillReturnResult(sqlmock.NewResult(11, 1)) + mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO outbound_items").WithArgs(int64(11), int64(7), 2).WillReturnResult(sqlmock.NewResult(12, 1)) + mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7)) + mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "out", 2, "outbound", int64(11)).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + order, err := NewOutboundService().Create(1, createOutboundRequest(), "operator") + if err != nil { + t.Fatalf("create outbound: %v", err) + } + if order.OrganizationID != 1 || order.WarehouseID != 10 || order.LocationID != 20 { + t.Fatalf("单据维度=%d/%d/%d, want 1/10/20", order.OrganizationID, order.WarehouseID, order.LocationID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// 删除别家公司的单据:锁定当前公司原单据时命中 ErrNoRows,不冲销任何库存。 +func TestOutboundDeleteCrossOrganizationReturnsNoRows(t *testing.T) { + mock := outboundMock(t) + mock.ExpectBegin() + mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(9), int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectRollback() + + err := NewOutboundService().Delete(1, 9) + if err != sql.ErrNoRows { + t.Fatalf("got %v, want sql.ErrNoRows", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +}