feat: 按公司隔离入出库单据流程

This commit is contained in:
文睿 张 2026-10-09 12:21:53 +08:00
parent ee18a2e56f
commit b0cab477db
15 changed files with 1259 additions and 176 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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("没有广播任何事件")
}
}

View File

@ -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 {

View File

@ -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 契约,
// 将来引入状态机时再改为真实读写。

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 的单据")
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,那种情况下必须保持原来的"带上阈值即启用"行为。

View File

@ -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
}

View File

@ -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)
}
}