feat: 按公司隔离入出库单据流程
This commit is contained in:
parent
ee18a2e56f
commit
b0cab477db
|
|
@ -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 {
|
if err != nil {
|
||||||
response.InternalError(c, err.Error())
|
response.InternalError(c, err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -494,7 +499,12 @@ func (h *Handler) GetInbound(c *gin.Context) {
|
||||||
return
|
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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
response.NotFound(c, "Inbound order not found")
|
response.NotFound(c, "Inbound order not found")
|
||||||
|
|
@ -539,8 +549,8 @@ func (h *Handler) CreateInbound(c *gin.Context) {
|
||||||
response.InternalError(c, err.Error())
|
response.InternalError(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Broadcast stock update to all connected clients
|
// Broadcast stock update to all connected clients(带上公司归属,供按公司投递过滤)
|
||||||
GetHub().BroadcastStockUpdate("inbound", order.ID, order)
|
GetHub().BroadcastStockUpdate("inbound", orgID, order.ID, order)
|
||||||
response.Created(c, "Inbound order created successfully", 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)
|
err = h.inboundSvc.Delete(orgID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
response.NotFound(c, "Inbound order not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
if errors.Is(err, service.ErrInventoryScopeUnavailable) {
|
if errors.Is(err, service.ErrInventoryScopeUnavailable) {
|
||||||
response.Error(c, 409, err.Error())
|
response.Error(c, 409, err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -588,6 +602,10 @@ func (h *Handler) UpdateInbound(c *gin.Context) {
|
||||||
|
|
||||||
order, err := h.inboundSvc.Update(orgID, id, &req)
|
order, err := h.inboundSvc.Update(orgID, id, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
response.NotFound(c, "Inbound order not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
if errors.Is(err, service.ErrMaterialNotFound) {
|
if errors.Is(err, service.ErrMaterialNotFound) {
|
||||||
response.BadRequest(c, "material not found")
|
response.BadRequest(c, "material not found")
|
||||||
return
|
return
|
||||||
|
|
@ -603,7 +621,7 @@ func (h *Handler) UpdateInbound(c *gin.Context) {
|
||||||
response.InternalError(c, err.Error())
|
response.InternalError(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
GetHub().BroadcastStockUpdate("inbound", order.ID, order)
|
GetHub().BroadcastStockUpdate("inbound", orgID, order.ID, order)
|
||||||
response.Success(c, order)
|
response.Success(c, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -614,7 +632,12 @@ func (h *Handler) GetInboundByMaterial(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
records, err := h.inboundSvc.GetByMaterialID(materialID)
|
orgID, ok := organizationID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := h.inboundSvc.GetByMaterialID(orgID, materialID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, err.Error())
|
response.InternalError(c, err.Error())
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
response.InternalError(c, err.Error())
|
response.InternalError(c, err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -664,7 +692,12 @@ func (h *Handler) GetOutbound(c *gin.Context) {
|
||||||
return
|
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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
response.NotFound(c, "Outbound order not found")
|
response.NotFound(c, "Outbound order not found")
|
||||||
|
|
@ -713,8 +746,8 @@ func (h *Handler) CreateOutbound(c *gin.Context) {
|
||||||
response.InternalError(c, err.Error())
|
response.InternalError(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Broadcast stock update to all connected clients
|
// Broadcast stock update to all connected clients(带上公司归属,供按公司投递过滤)
|
||||||
GetHub().BroadcastStockUpdate("outbound", order.ID, order)
|
GetHub().BroadcastStockUpdate("outbound", orgID, order.ID, order)
|
||||||
response.Created(c, "Outbound order created successfully", 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)
|
err = h.outboundSvc.Delete(orgID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
response.NotFound(c, "Outbound order not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
if errors.Is(err, service.ErrInventoryScopeUnavailable) {
|
if errors.Is(err, service.ErrInventoryScopeUnavailable) {
|
||||||
response.Error(c, 409, err.Error())
|
response.Error(c, 409, err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -766,6 +803,10 @@ func (h *Handler) UpdateOutbound(c *gin.Context) {
|
||||||
}
|
}
|
||||||
order, err := h.outboundSvc.Update(orgID, id, &req, operator)
|
order, err := h.outboundSvc.Update(orgID, id, &req, operator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
response.NotFound(c, "Outbound order not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
if errors.Is(err, service.ErrMaterialNotFound) {
|
if errors.Is(err, service.ErrMaterialNotFound) {
|
||||||
response.BadRequest(c, "material not found")
|
response.BadRequest(c, "material not found")
|
||||||
return
|
return
|
||||||
|
|
@ -785,7 +826,7 @@ func (h *Handler) UpdateOutbound(c *gin.Context) {
|
||||||
response.InternalError(c, err.Error())
|
response.InternalError(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
GetHub().BroadcastStockUpdate("outbound", order.ID, order)
|
GetHub().BroadcastStockUpdate("outbound", orgID, order.ID, order)
|
||||||
response.Success(c, order)
|
response.Success(c, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -796,7 +837,12 @@ func (h *Handler) GetOutboundByMaterial(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
records, err := h.outboundSvc.GetByMaterialID(materialID)
|
orgID, ok := organizationID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := h.outboundSvc.GetByMaterialID(orgID, materialID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, err.Error())
|
response.InternalError(c, err.Error())
|
||||||
return
|
return
|
||||||
|
|
|
||||||
231
internal/handler/order_flow_integration_test.go
Normal file
231
internal/handler/order_flow_integration_test.go
Normal 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
|
||||||
|
}
|
||||||
244
internal/handler/order_organization_test.go
Normal file
244
internal/handler/order_organization_test.go
Normal 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("没有广播任何事件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,10 +36,12 @@ type Hub struct {
|
||||||
|
|
||||||
// StockUpdateEvent represents a stock update notification
|
// StockUpdateEvent represents a stock update notification
|
||||||
type StockUpdateEvent struct {
|
type StockUpdateEvent struct {
|
||||||
Type string `json:"type"` // "stock_update"
|
Type string `json:"type"` // "stock_update"
|
||||||
Action string `json:"action"` // "inbound" or "outbound"
|
Action string `json:"action"` // "inbound" or "outbound"
|
||||||
OrderID int64 `json:"order_id"`
|
// OrganizationID 是本次库存变化所属公司,供按公司投递过滤(Task 5D 按它路由)。
|
||||||
Data interface{} `json:"data,omitempty"`
|
OrganizationID int64 `json:"organization_id"`
|
||||||
|
OrderID int64 `json:"order_id"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var hubInstance *Hub
|
var hubInstance *Hub
|
||||||
|
|
@ -93,12 +95,13 @@ func (h *Hub) run() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// BroadcastStockUpdate sends a stock update event to all connected clients
|
// 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{
|
event := StockUpdateEvent{
|
||||||
Type: "stock_update",
|
Type: "stock_update",
|
||||||
Action: action,
|
Action: action,
|
||||||
OrderID: orderID,
|
OrganizationID: organizationID,
|
||||||
Data: data,
|
OrderID: orderID,
|
||||||
|
Data: data,
|
||||||
}
|
}
|
||||||
msg, err := json.Marshal(event)
|
msg, err := json.Marshal(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,13 @@ type InboundOrder struct {
|
||||||
Handler string `json:"handler"`
|
Handler string `json:"handler"`
|
||||||
Supplier string `json:"supplier"`
|
Supplier string `json:"supplier"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// OrganizationID/WarehouseID/LocationID 是单据落库的库存维度,由服务端按当前
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
// 公司解析后填写,不接受前端伪造的归属。
|
||||||
|
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"
|
// Status 不落库:inbound_orders 表没有 status 列,状态由查询层统一写死为 "completed"
|
||||||
// (单据创建即完成,当前没有中间态)。保留字段是为了稳定 JSON 契约,
|
// (单据创建即完成,当前没有中间态)。保留字段是为了稳定 JSON 契约,
|
||||||
// 将来引入状态机时再改为真实读写。
|
// 将来引入状态机时再改为真实读写。
|
||||||
|
|
@ -92,8 +97,13 @@ type OutboundOrder struct {
|
||||||
Handler string `json:"handler"`
|
Handler string `json:"handler"`
|
||||||
Receiver string `json:"receiver"`
|
Receiver string `json:"receiver"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// OrganizationID/WarehouseID/LocationID 是单据落库的库存维度,由服务端按当前
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
// 公司解析后填写,不接受前端伪造的归属。
|
||||||
|
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"
|
// Status 不落库:outbound_orders 表没有 status 列,状态由查询层统一写死为 "completed"
|
||||||
// (单据创建即完成,当前没有中间态)。保留字段是为了稳定 JSON 契约,
|
// (单据创建即完成,当前没有中间态)。保留字段是为了稳定 JSON 契约,
|
||||||
// 将来引入状态机时再改为真实读写。
|
// 将来引入状态机时再改为真实读写。
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ import (
|
||||||
"github.com/wmms/wmms/pkg/database"
|
"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{}
|
type InboundRepo struct{}
|
||||||
|
|
||||||
func NewInboundRepo() *InboundRepo {
|
func NewInboundRepo() *InboundRepo {
|
||||||
|
|
@ -32,8 +39,10 @@ func (r *InboundRepo) CreateTx(tx *sql.Tx, order *model.InboundOrder) error {
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `INSERT INTO inbound_orders (inbound_date, operator, handler, supplier, remark) VALUES (?, ?, ?, ?, ?)`
|
// 三个库存维度由服务端解析后显式落库;表上这三列 NOT NULL 无默认值,缺一即报错,
|
||||||
result, err := tx.Exec(query, order.InboundDate, order.Operator, order.Handler, order.Supplier, order.Remark)
|
// 不会静默写进默认公司。
|
||||||
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -68,31 +77,47 @@ func (r *InboundRepo) CreateTx(tx *sql.Tx, order *model.InboundOrder) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) {
|
// loadInboundItems 读取单据明细,并 JOIN 头表与物料表把明细限制在同一组织内:
|
||||||
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 = ?`
|
func loadInboundItems(q orderQuerier, orderID int64) ([]model.InboundItem, error) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsQuery := `SELECT ii.id, ii.inbound_order_id, ii.material_id, ii.quantity, m.material_code, m.name, m.status
|
itemsQuery := `SELECT ii.id, ii.inbound_order_id, ii.material_id, ii.quantity, m.material_code, m.name, m.status
|
||||||
FROM inbound_items ii
|
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 = ?`
|
WHERE ii.inbound_order_id = ?`
|
||||||
rows, err := database.DB.Query(itemsQuery, id)
|
rows, err := q.Query(itemsQuery, orderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []model.InboundItem
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item model.InboundItem
|
var item model.InboundItem
|
||||||
err := rows.Scan(&item.ID, &item.InboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus)
|
if err := rows.Scan(&item.ID, &item.InboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
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 列,统一在这里给出。引入状态机时改为真实读写。
|
// 状态不落库:inbound_orders 没有 status 列,统一在这里给出。引入状态机时改为真实读写。
|
||||||
|
|
@ -100,9 +125,20 @@ func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) {
|
||||||
return order, nil
|
return order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrder, int64, error) {
|
func (r *InboundRepo) GetByID(organizationID, id int64) (*model.InboundOrder, error) {
|
||||||
countQuery := `SELECT COUNT(*) FROM inbound_orders WHERE 1=1`
|
return r.getByID(database.DB, organizationID, id, false)
|
||||||
var countArgs []interface{}
|
}
|
||||||
|
|
||||||
|
// 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 != "" {
|
if search != "" {
|
||||||
countQuery += ` AND (handler LIKE ? OR supplier LIKE ?)`
|
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
|
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`
|
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 = ?`
|
||||||
var args []interface{}
|
args := []interface{}{organizationID}
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
query += ` AND (handler LIKE ? OR supplier LIKE ?)`
|
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
|
var orders []model.InboundOrder
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var order model.InboundOrder
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -147,14 +183,17 @@ func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrd
|
||||||
return orders, total, nil
|
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
|
query := `SELECT ii.id, ii.inbound_order_id, io.handler, io.supplier, ii.quantity, ii.created_at
|
||||||
FROM inbound_items ii
|
FROM inbound_items ii
|
||||||
JOIN inbound_orders io ON ii.inbound_order_id = io.id
|
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`
|
ORDER BY ii.id DESC`
|
||||||
|
|
||||||
rows, err := database.DB.Query(query, materialID)
|
rows, err := database.DB.Query(query, materialID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -172,18 +211,18 @@ func (r *InboundRepo) GetByMaterialID(materialID int64) ([]model.InboundRecord,
|
||||||
return records, nil
|
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
|
var inboundDate time.Time
|
||||||
query := `SELECT inbound_date FROM inbound_orders WHERE id = ?`
|
query := `SELECT inbound_date FROM inbound_orders WHERE id = ? AND organization_id = ?`
|
||||||
err := database.DB.QueryRow(query, id).Scan(&inboundDate)
|
err := database.DB.QueryRow(query, id, organizationID).Scan(&inboundDate)
|
||||||
return inboundDate, err
|
return inboundDate, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *InboundRepo) Delete(id int64) error {
|
func (r *InboundRepo) Delete(organizationID, id int64) error {
|
||||||
return r.DeleteTx(nil, id)
|
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 {
|
if tx == nil {
|
||||||
var err error
|
var err error
|
||||||
tx, err = database.DB.Begin()
|
tx, err = database.DB.Begin()
|
||||||
|
|
@ -191,21 +230,21 @@ func (r *InboundRepo) DeleteTx(tx *sql.Tx, id int64) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *InboundRepo) Update(id int64, req *model.UpdateInboundRequest) error {
|
func (r *InboundRepo) Update(organizationID, id int64, req *model.UpdateInboundRequest) error {
|
||||||
return r.UpdateTx(nil, id, req)
|
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
|
shouldCommit := false
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
var err error
|
var err error
|
||||||
|
|
@ -219,9 +258,20 @@ func (r *InboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateInboundReq
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update order basic fields
|
// 先确认目标单据属于本公司:跨公司的单据在这里直接返回 ErrNoRows,后面的明细
|
||||||
query := `UPDATE inbound_orders SET handler = ?, supplier = ?, remark = ? WHERE id = ?`
|
// 删除/插入绝不会碰到别家公司的单据(明细表没有组织列,只能靠头表归属收口)。
|
||||||
_, err := tx.Exec(query, req.Handler, req.Supplier, req.Remark, id)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
175
internal/repository/inbound_organization_test.go
Normal file
175
internal/repository/inbound_organization_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,16 +20,17 @@ func TestInboundListConvertsLegacyNullDisplayFields(t *testing.T) {
|
||||||
})
|
})
|
||||||
database.DB = db
|
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))
|
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)
|
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(20, 0).WillReturnRows(
|
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(1), 20, 0).WillReturnRows(
|
||||||
sqlmock.NewRows([]string{"id", "inbound_no", "operator", "inbound_date", "handler", "supplier", "remark", "created_at", "updated_at"}).
|
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, "张三", "供应商", "", now, now),
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("list inbound orders with legacy NULL fields: %v", err)
|
t.Fatalf("list inbound orders with legacy NULL fields: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -52,16 +53,17 @@ func TestOutboundListConvertsLegacyNullDisplayFields(t *testing.T) {
|
||||||
})
|
})
|
||||||
database.DB = db
|
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))
|
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)
|
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(20, 0).WillReturnRows(
|
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(1), 20, 0).WillReturnRows(
|
||||||
sqlmock.NewRows([]string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "created_at", "updated_at"}).
|
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, "李四", "装配车间", "", now, now),
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("list outbound orders with legacy NULL fields: %v", err)
|
t.Fatalf("list outbound orders with legacy NULL fields: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,10 @@ func (r *OutboundRepo) CreateTx(tx *sql.Tx, order *model.OutboundOrder) error {
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, remark) VALUES (?, ?, ?, ?, ?)`
|
// 三个库存维度由服务端解析后显式落库;表上这三列 NOT NULL 无默认值,缺一即报错,
|
||||||
result, err := tx.Exec(query, order.OutboundDate, order.Operator, order.Handler, order.Receiver, order.Remark)
|
// 不会静默写进默认公司。
|
||||||
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -68,31 +70,47 @@ func (r *OutboundRepo) CreateTx(tx *sql.Tx, order *model.OutboundOrder) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) {
|
// loadOutboundItems 读取单据明细,并 JOIN 头表与物料表把明细限制在同一组织内:
|
||||||
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 = ?`
|
func loadOutboundItems(q orderQuerier, orderID int64) ([]model.OutboundItem, error) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsQuery := `SELECT oi.id, oi.outbound_order_id, oi.material_id, oi.quantity, m.material_code, m.name, m.status
|
itemsQuery := `SELECT oi.id, oi.outbound_order_id, oi.material_id, oi.quantity, m.material_code, m.name, m.status
|
||||||
FROM outbound_items oi
|
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 = ?`
|
WHERE oi.outbound_order_id = ?`
|
||||||
rows, err := database.DB.Query(itemsQuery, id)
|
rows, err := q.Query(itemsQuery, orderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []model.OutboundItem
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item model.OutboundItem
|
var item model.OutboundItem
|
||||||
err := rows.Scan(&item.ID, &item.OutboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus)
|
if err := rows.Scan(&item.ID, &item.OutboundOrderID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName, &item.MaterialStatus); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
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 列,统一在这里给出。引入状态机时改为真实读写。
|
// 状态不落库:outbound_orders 没有 status 列,统一在这里给出。引入状态机时改为真实读写。
|
||||||
|
|
@ -100,9 +118,20 @@ func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) {
|
||||||
return order, nil
|
return order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundOrder, int64, error) {
|
func (r *OutboundRepo) GetByID(organizationID, id int64) (*model.OutboundOrder, error) {
|
||||||
countQuery := `SELECT COUNT(*) FROM outbound_orders WHERE 1=1`
|
return r.getByID(database.DB, organizationID, id, false)
|
||||||
var countArgs []interface{}
|
}
|
||||||
|
|
||||||
|
// 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 != "" {
|
if search != "" {
|
||||||
countQuery += ` AND (handler LIKE ? OR receiver LIKE ?)`
|
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
|
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`
|
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 = ?`
|
||||||
var args []interface{}
|
args := []interface{}{organizationID}
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
query += ` AND (handler LIKE ? OR receiver LIKE ?)`
|
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
|
var orders []model.OutboundOrder
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var order model.OutboundOrder
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -147,14 +176,17 @@ func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundO
|
||||||
return orders, total, nil
|
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
|
query := `SELECT oi.id, oi.outbound_order_id, oo.handler, oo.receiver, oi.quantity, oi.created_at
|
||||||
FROM outbound_items oi
|
FROM outbound_items oi
|
||||||
JOIN outbound_orders oo ON oi.outbound_order_id = oo.id
|
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`
|
ORDER BY oi.id DESC`
|
||||||
|
|
||||||
rows, err := database.DB.Query(query, materialID)
|
rows, err := database.DB.Query(query, materialID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -172,18 +204,18 @@ func (r *OutboundRepo) GetByMaterialID(materialID int64) ([]model.OutboundRecord
|
||||||
return records, nil
|
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
|
var outboundDate time.Time
|
||||||
query := `SELECT outbound_date FROM outbound_orders WHERE id = ?`
|
query := `SELECT outbound_date FROM outbound_orders WHERE id = ? AND organization_id = ?`
|
||||||
err := database.DB.QueryRow(query, id).Scan(&outboundDate)
|
err := database.DB.QueryRow(query, id, organizationID).Scan(&outboundDate)
|
||||||
return outboundDate, err
|
return outboundDate, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *OutboundRepo) Delete(id int64) error {
|
func (r *OutboundRepo) Delete(organizationID, id int64) error {
|
||||||
return r.DeleteTx(nil, id)
|
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 {
|
if tx == nil {
|
||||||
var err error
|
var err error
|
||||||
tx, err = database.DB.Begin()
|
tx, err = database.DB.Begin()
|
||||||
|
|
@ -191,21 +223,21 @@ func (r *OutboundRepo) DeleteTx(tx *sql.Tx, id int64) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *OutboundRepo) Update(id int64, req *model.UpdateOutboundRequest) error {
|
func (r *OutboundRepo) Update(organizationID, id int64, req *model.UpdateOutboundRequest) error {
|
||||||
return r.UpdateTx(nil, id, req)
|
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
|
shouldCommit := false
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
var err error
|
var err error
|
||||||
|
|
@ -219,9 +251,20 @@ func (r *OutboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateOutboundR
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update order basic fields
|
// 先确认目标单据属于本公司:跨公司的单据在这里直接返回 ErrNoRows,后面的明细
|
||||||
query := `UPDATE outbound_orders SET operator = ?, handler = ?, receiver = ?, remark = ? WHERE id = ?`
|
// 删除/插入绝不会碰到别家公司的单据(明细表没有组织列,只能靠头表归属收口)。
|
||||||
_, err := tx.Exec(query, req.Operator, req.Handler, req.Receiver, req.Remark, id)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
119
internal/repository/outbound_organization_test.go
Normal file
119
internal/repository/outbound_organization_test.go
Normal 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 的单据")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -448,9 +448,11 @@ func (r *StockRepo) UpdateWarningStockTx(tx *sql.Tx, scope model.InventoryScope,
|
||||||
return upsertStockWarningRule(tx, scope, materialID, minimumAvailable, enabled)
|
return upsertStockWarningRule(tx, scope, materialID, minimumAvailable, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *StockRepo) DeleteLogs(orderType string, orderID int64) error {
|
// DeleteLogs 删除指定公司的库存流水:组织、关联类型、单据 ID 三个条件同时约束,
|
||||||
query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?`
|
// 否则凭别家公司的单据 ID 就能删掉它的流水。
|
||||||
_, err := database.DB.Exec(query, orderType, orderID)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -468,10 +470,10 @@ func (r *StockRepo) CreateLogTx(tx *sql.Tx, log *model.StockLog) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLogsTx deletes stock logs within a transaction
|
// DeleteLogsTx 在事务内删除指定公司的库存流水,同样同时约束组织、关联类型和单据 ID。
|
||||||
func (r *StockRepo) DeleteLogsTx(tx *sql.Tx, orderType string, orderID int64) error {
|
func (r *StockRepo) DeleteLogsTx(tx *sql.Tx, organizationID int64, orderType string, orderID int64) error {
|
||||||
query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?`
|
query := `DELETE FROM stock_logs WHERE organization_id = ? AND related_order_type = ? AND related_order_id = ?`
|
||||||
_, err := tx.Exec(query, orderType, orderID)
|
_, err := tx.Exec(query, organizationID, orderType, orderID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ func (s *InboundService) Create(organizationID int64, req *model.CreateInboundRe
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 单据归属只取服务端解析出的当前公司维度,请求体里伪造的归属一律被覆盖。
|
||||||
|
order.OrganizationID = scope.OrganizationID
|
||||||
|
order.WarehouseID = scope.WarehouseID
|
||||||
|
order.LocationID = scope.LocationID
|
||||||
|
|
||||||
// Start transaction
|
// Start transaction
|
||||||
tx, err := repository.BeginTx()
|
tx, err := repository.BeginTx()
|
||||||
|
|
@ -143,31 +147,19 @@ func (s *InboundService) Create(organizationID int64, req *model.CreateInboundRe
|
||||||
return order, nil
|
return order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetByID(id int64) (*model.InboundOrder, error) {
|
func (s *InboundService) GetByID(organizationID, id int64) (*model.InboundOrder, error) {
|
||||||
return s.inboundRepo.GetByID(id)
|
return s.inboundRepo.GetByID(organizationID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) List(search string, limit, offset int) ([]model.InboundOrder, int64, error) {
|
func (s *InboundService) List(organizationID int64, search string, limit, offset int) ([]model.InboundOrder, int64, error) {
|
||||||
return s.inboundRepo.List(search, limit, offset)
|
return s.inboundRepo.List(organizationID, search, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetByMaterialID(materialID int64) ([]model.InboundRecord, error) {
|
func (s *InboundService) GetByMaterialID(organizationID, materialID int64) ([]model.InboundRecord, error) {
|
||||||
return s.inboundRepo.GetByMaterialID(materialID)
|
return s.inboundRepo.GetByMaterialID(organizationID, materialID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) Delete(organizationID, id int64) error {
|
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
|
// Start transaction
|
||||||
tx, err := repository.BeginTx()
|
tx, err := repository.BeginTx()
|
||||||
if err != nil {
|
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
|
// Reverse stock for each item within transaction
|
||||||
for _, item := range order.Items {
|
for _, item := range order.Items {
|
||||||
err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(-item.Quantity)))
|
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
|
// Delete stock logs within transaction(只删本公司的流水)
|
||||||
err = s.stockRepo.DeleteLogsTx(tx, "inbound", id)
|
err = s.stockRepo.DeleteLogsTx(tx, organizationID, "inbound", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the inbound order (items will be cascade deleted)
|
// 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 {
|
if err != nil {
|
||||||
return err
|
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) {
|
func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInboundRequest) (*model.InboundOrder, error) {
|
||||||
// Get old order data
|
// Get old order data(带组织条件,别家公司的单据在这里就命中 ErrNoRows)
|
||||||
oldOrder, err := s.inboundRepo.GetByID(id)
|
oldOrder, err := s.inboundRepo.GetByID(organizationID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// Delete old stock logs
|
||||||
err = s.stockRepo.DeleteLogsTx(tx, "inbound", id)
|
err = s.stockRepo.DeleteLogsTx(tx, organizationID, "inbound", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update order in repository
|
// Update order in repository
|
||||||
err = s.inboundRepo.UpdateTx(tx, id, req)
|
err = s.inboundRepo.UpdateTx(tx, organizationID, id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -331,7 +342,7 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get updated order with material details
|
// Get updated order with material details
|
||||||
updatedOrder, err := s.inboundRepo.GetByID(id)
|
updatedOrder, err := s.inboundRepo.GetByID(organizationID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,76 @@
|
||||||
package service
|
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、
|
// resolveWarningEnabled 决定入库时写预警规则要不要启用。老客户端只会传 warning_stock、
|
||||||
// 从不传 warning_enabled,那种情况下必须保持原来的"带上阈值即启用"行为。
|
// 从不传 warning_enabled,那种情况下必须保持原来的"带上阈值即启用"行为。
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ func (s *OutboundService) Create(organizationID int64, req *model.CreateOutbound
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 单据归属只取服务端解析出的当前公司维度,请求体里伪造的归属一律被覆盖。
|
||||||
|
order.OrganizationID = scope.OrganizationID
|
||||||
|
order.WarehouseID = scope.WarehouseID
|
||||||
|
order.LocationID = scope.LocationID
|
||||||
|
|
||||||
// Validate all items first (outside transaction)
|
// Validate all items first (outside transaction)
|
||||||
for i, item := range req.Items {
|
for i, item := range req.Items {
|
||||||
|
|
@ -153,21 +157,29 @@ func materialSortedIndexes(materialIDs []int64) []int {
|
||||||
return indexes
|
return indexes
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OutboundService) GetByID(id int64) (*model.OutboundOrder, error) {
|
func (s *OutboundService) GetByID(organizationID, id int64) (*model.OutboundOrder, error) {
|
||||||
return s.outboundRepo.GetByID(id)
|
return s.outboundRepo.GetByID(organizationID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OutboundService) List(search string, limit, offset int) ([]model.OutboundOrder, int64, error) {
|
func (s *OutboundService) List(organizationID int64, search string, limit, offset int) ([]model.OutboundOrder, int64, error) {
|
||||||
return s.outboundRepo.List(search, limit, offset)
|
return s.outboundRepo.List(organizationID, search, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OutboundService) GetByMaterialID(materialID int64) ([]model.OutboundRecord, error) {
|
func (s *OutboundService) GetByMaterialID(organizationID, materialID int64) ([]model.OutboundRecord, error) {
|
||||||
return s.outboundRepo.GetByMaterialID(materialID)
|
return s.outboundRepo.GetByMaterialID(organizationID, materialID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OutboundService) Delete(organizationID, id int64) error {
|
func (s *OutboundService) Delete(organizationID, id int64) error {
|
||||||
// Get the outbound order with items
|
// Start transaction
|
||||||
order, err := s.outboundRepo.GetByID(id)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -178,13 +190,6 @@ func (s *OutboundService) Delete(organizationID, id int64) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start transaction
|
|
||||||
tx, err := repository.BeginTx()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// 按物料 ID 升序把库存加回,固定加锁顺序。
|
// 按物料 ID 升序把库存加回,固定加锁顺序。
|
||||||
itemIDs := make([]int64, len(order.Items))
|
itemIDs := make([]int64, len(order.Items))
|
||||||
for i := range order.Items {
|
for i := range order.Items {
|
||||||
|
|
@ -198,14 +203,14 @@ func (s *OutboundService) Delete(organizationID, id int64) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete stock logs within transaction
|
// Delete stock logs within transaction(只删本公司的流水)
|
||||||
err = s.stockRepo.DeleteLogsTx(tx, "outbound", id)
|
err = s.stockRepo.DeleteLogsTx(tx, organizationID, "outbound", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the outbound order (items will be cascade deleted)
|
// 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 {
|
if err != nil {
|
||||||
return err
|
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) {
|
func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutboundRequest, operator string) (*model.OutboundOrder, error) {
|
||||||
// Get old order data
|
// Get old order data(带组织条件,别家公司的单据在这里就命中 ErrNoRows)
|
||||||
oldOrder, err := s.outboundRepo.GetByID(id)
|
oldOrder, err := s.outboundRepo.GetByID(organizationID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -273,8 +278,14 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// 在事务中锁定当前公司的原单据,并以锁下的原明细为准冲销;锁只加在本公司单据上。
|
||||||
|
oldOrder, err = s.outboundRepo.GetByIDForUpdateTx(tx, organizationID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Delete old stock logs
|
// Delete old stock logs
|
||||||
err = s.stockRepo.DeleteLogsTx(tx, "outbound", id)
|
err = s.stockRepo.DeleteLogsTx(tx, organizationID, "outbound", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -283,7 +294,7 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb
|
||||||
// 客户端不能指定操作人;编辑也由当前登录用户接管操作人和兼容字段 handler。
|
// 客户端不能指定操作人;编辑也由当前登录用户接管操作人和兼容字段 handler。
|
||||||
req.Handler = operator
|
req.Handler = operator
|
||||||
req.Operator = operator
|
req.Operator = operator
|
||||||
err = s.outboundRepo.UpdateTx(tx, id, req)
|
err = s.outboundRepo.UpdateTx(tx, organizationID, id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +359,7 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get updated order with material details
|
// Get updated order with material details
|
||||||
updatedOrder, err := s.outboundRepo.GetByID(id)
|
updatedOrder, err := s.outboundRepo.GetByID(organizationID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -22,11 +24,16 @@ func outboundMock(t *testing.T) sqlmock.Sqlmock {
|
||||||
return mock
|
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) {
|
func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) {
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), id).
|
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"}).
|
WillReturnRows(activeMaterialRow(id))
|
||||||
AddRow(id, 1, "M-1", "Material", nil, "", nil, 1, 1, "pc", "active", 1, time.Now(), time.Now()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,16 +110,28 @@ func TestOutboundCreateRollsBackWhenMaterialReadFailsAfterOrderInsert(t *testing
|
||||||
func TestOutboundUpdateDoesNotCountRestoredStockTwice(t *testing.T) {
|
func TestOutboundUpdateDoesNotCountRestoredStockTwice(t *testing.T) {
|
||||||
mock := outboundMock(t)
|
mock := outboundMock(t)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11)).WillReturnRows(
|
headColumns := []string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"}
|
||||||
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))
|
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(
|
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)
|
expectStockScope(mock, 1)
|
||||||
expectActiveMaterial(mock, 7)
|
expectActiveMaterial(mock, 7)
|
||||||
// 预检:当前 2 + 旧单已扣 2 = 4,够新单的 4。
|
// 预检:当前 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.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.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("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectExec("DELETE FROM outbound_items").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))
|
mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(13, 1))
|
||||||
|
|
@ -149,3 +168,50 @@ func TestOutboundCreateRejectsWhenInventoryScopeUnavailable(t *testing.T) {
|
||||||
t.Fatal(err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user