fix: 修正普通审批 scope、出库更新加锁顺序与预警物料归属校验

This commit is contained in:
文睿 张 2026-10-09 11:19:39 +08:00
parent e3cfe88d4e
commit ee18a2e56f
7 changed files with 199 additions and 101 deletions

View File

@ -966,6 +966,19 @@ func (h *Handler) UpdateWarningStock(c *gin.Context) {
if !ok {
return
}
// 先校验物料归属:预警规则的外键指向全局 materials(id),不校验的话写别家公司的
// material_id 会静默成功。跨公司一律 404,与库存详情、流水保持一致。
exists, err := h.stockSvc.MaterialExists(orgID, materialID)
if err != nil {
response.InternalError(c, err.Error())
return
}
if !exists {
response.NotFound(c, "Material not found")
return
}
// 改变库存域的写操作必须落在当前公司启用的仓库、库位上;缺失或停用时回 409 并给出
// 具体配置提示,而不是写进默认公司或静默成功。
scope, err := service.ResolveInventoryScope(orgID, true)

View File

@ -114,7 +114,10 @@ func TestStockWarningWriteReturnsConflictWhenScopeUnavailable(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
// 该公司没有仓库:Summary 返回 nil。
// 物料属于本公司(先通过归属校验),但公司没有仓库:Summary 返回 nil。
mock.ExpectQuery(`(?s)`+stockByMaterialPattern).WithArgs(int64(1), int64(5)).
WillReturnRows(sqlmock.NewRows(stockMaterialColumns()).
AddRow(5, "MAT-005", "轴承", "6204", "个", "0.000000", "0.000000", true, true, "A01", "A01-01"))
mock.ExpectQuery(stockWarehousePattern).WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows(warehouseSummaryColumns()))
@ -129,3 +132,23 @@ func TestStockWarningWriteReturnsConflictWhenScopeUnavailable(t *testing.T) {
t.Fatal(err)
}
}
// TestStockWarningWriteRejectsAnotherOrganizationsMaterial:预警规则的 material_id 外键指向
// 全局 materials(id),写别家公司的物料必须在归属校验阶段回 404,且不执行任何 upsert。
func TestStockWarningWriteRejectsAnotherOrganizationsMaterial(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
// 物料 7 属于公司 2:带 organization_id=1 的读取查不到。
mock.ExpectQuery(`(?s)`+stockByMaterialPattern).WithArgs(int64(1), int64(7)).
WillReturnRows(sqlmock.NewRows(stockMaterialColumns()))
w := newStockRequest(t, "PUT", "/api/stock/7/warning", `{"warning_stock":10}`, 1)
if w.Code != 404 {
t.Fatalf("跨公司物料写预警应当返回 404,实际 status=%d body=%s", w.Code, w.Body.String())
}
// 只登记了归属校验查询:一旦越过它去 upsert,sqlmock 会因未预期语句报错。
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -104,25 +104,24 @@ func (s *ApplicationService) Approve(organizationID, id int64, approverID int64,
return ErrApplicationNotPending
}
// 库存维度由服务端按当前公司解析;审批自动出库会扣减本公司库存。
scope, err := ResolveInventoryScope(organizationID, true)
if err != nil {
return err
}
// Check stock for all items
for _, item := range app.Items {
stock, err := s.stockRepo.GetStock(scope, item.MaterialID)
if err != nil {
return ErrMaterialNotFound
}
if stock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
return ErrInsufficientStock
}
}
// Auto outbound if requested
if autoOutbound {
// 只有自动出库才会改变库存,才需要解析本公司启用的仓库、库位并做库存检查。
// 普通审批(不扣库存)沿用已有规则:只要求公司授权和相关物料合法,不要求仓库配置。
scope, err := ResolveInventoryScope(organizationID, true)
if err != nil {
return err
}
for _, item := range app.Items {
stock, err := s.stockRepo.GetStock(scope, item.MaterialID)
if err != nil {
return ErrMaterialNotFound
}
if stock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
return ErrInsufficientStock
}
}
// Start transaction
tx, err := repository.BeginTx()
if err != nil {
@ -245,9 +244,13 @@ func (s *ApplicationService) Cancel(id int64, userID int64) error {
}
func (s *ApplicationService) GetMaterialStock(organizationID, materialID int64) (decimal.Decimal, error) {
// 只读查询:停用的仓库、库位仍返回其维度,便于核验历史余额。
// 只读查询:停用的仓库、库位仍返回其维度,便于核验历史余额;公司没有仓库时按约束
// "无余额返回 0" 处理,而不是报错。
scope, err := ResolveInventoryScope(organizationID, false)
if err != nil {
if errors.Is(err, ErrInventoryScopeUnavailable) {
return decimal.Zero, nil
}
return decimal.Zero, err
}
return s.stockRepo.GetStock(scope, materialID)

View File

@ -0,0 +1,31 @@
package service
import (
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
)
// 普通审批(不扣库存)沿用已有规则:只要求公司授权、状态待审和相关物料合法,公司没有
// 启用仓库也必须能审批,不能因为无法解析库存维度而返回 ErrInventoryScopeUnavailable。
// 这里只登记审批必需的查询;若代码先解析仓库 scope,sqlmock 会因未预期语句失败。
func TestApproveWithoutAutoOutboundDoesNotRequireInventoryScope(t *testing.T) {
mock := outboundMock(t)
now := time.Now()
mock.ExpectQuery("SELECT a.id, a.user_id, a.purpose").WithArgs(int64(1)).WillReturnRows(
sqlmock.NewRows([]string{"id", "user_id", "purpose", "status", "rejection_reason", "approved_by", "created_at", "updated_at", "username"}).
AddRow(1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice"))
mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1)).WillReturnRows(
sqlmock.NewRows([]string{"id", "application_id", "material_id", "quantity", "material_code", "name"}).
AddRow(11, 1, 7, 2, "M-1", "Material"))
mock.ExpectExec("UPDATE material_applications SET status").WithArgs(model.ApplicationStatusApproved, int64(9), int64(1)).WillReturnResult(sqlmock.NewResult(0, 1))
if err := NewApplicationService().Approve(1, 1, 9, false); err != nil {
t.Fatalf("普通审批不应要求仓库配置,got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -245,18 +245,6 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou
}
}()
// Reverse stock for old items (subtract what was previously added)
for _, item := range oldOrder.Items {
_, err = s.stockRepo.GetStockForUpdateTx(tx, scope, item.MaterialID)
if err != nil {
return nil, err
}
err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(-item.Quantity)))
if err != nil {
return nil, err
}
}
// Delete old stock logs
err = s.stockRepo.DeleteLogsTx(tx, "inbound", id)
if err != nil {
@ -269,40 +257,71 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou
return nil, err
}
// Add stock for new items and create logs
for _, item := range req.Items {
_, err = s.stockRepo.GetStockForUpdateTx(tx, scope, item.MaterialID)
if err != nil {
return nil, err
}
err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil {
return nil, err
}
// 阈值随明细一起更新,nil 表示不改动已存的值,避免编辑旧入库单时把刚调好的
// 阈值覆盖回旧数;启用状态同理,nil 表示不改动。
if item.WarningStock != nil {
err = s.stockRepo.UpdateWarningStockTx(tx, scope, item.MaterialID, *item.WarningStock,
resolveWarningEnabled(item.WarningEnabled))
// 旧明细(冲销)与新明细(补货)合并后按物料 ID 统一升序处理,逐物料先冲销旧量、
// 再补新量。两个并发的 Update 交换物料时也会以相同全局顺序加锁,不会死锁。
oldQtyByMaterial := make(map[int64]int)
involved := make(map[int64]bool)
for _, item := range oldOrder.Items {
oldQtyByMaterial[item.MaterialID] += item.Quantity
involved[item.MaterialID] = true
}
newItemIndexes := make(map[int64][]int)
for i := range req.Items {
materialID := req.Items[i].MaterialID
newItemIndexes[materialID] = append(newItemIndexes[materialID], i)
involved[materialID] = true
}
materialIDs := make([]int64, 0, len(involved))
for materialID := range involved {
materialIDs = append(materialIDs, materialID)
}
for _, idx := range materialSortedIndexes(materialIDs) {
materialID := materialIDs[idx]
if qty := oldQtyByMaterial[materialID]; qty > 0 {
_, err = s.stockRepo.GetStockForUpdateTx(tx, scope, materialID)
if err != nil {
return nil, err
}
err = s.stockRepo.UpdateStockTx(tx, scope, materialID, decimal.NewFromInt(int64(-qty)))
if err != nil {
return nil, err
}
}
for _, i := range newItemIndexes[materialID] {
item := req.Items[i]
_, err = s.stockRepo.GetStockForUpdateTx(tx, scope, materialID)
if err != nil {
return nil, err
}
err = s.stockRepo.UpdateStockTx(tx, scope, materialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil {
return nil, err
}
stockLog := &model.StockLog{
OrganizationID: scope.OrganizationID,
WarehouseID: scope.WarehouseID,
LocationID: scope.LocationID,
MaterialID: item.MaterialID,
Type: "in",
Quantity: item.Quantity,
RelatedOrderType: "inbound",
RelatedOrderID: id,
}
err = s.stockRepo.CreateLogTx(tx, stockLog)
if err != nil {
return nil, err
// 阈值随明细一起更新,nil 表示不改动已存的值,避免编辑旧入库单时把刚调好的
// 阈值覆盖回旧数;启用状态同理,nil 表示不改动。
if item.WarningStock != nil {
err = s.stockRepo.UpdateWarningStockTx(tx, scope, materialID, *item.WarningStock,
resolveWarningEnabled(item.WarningEnabled))
if err != nil {
return nil, err
}
}
stockLog := &model.StockLog{
OrganizationID: scope.OrganizationID,
WarehouseID: scope.WarehouseID,
LocationID: scope.LocationID,
MaterialID: materialID,
Type: "in",
Quantity: item.Quantity,
RelatedOrderType: "inbound",
RelatedOrderID: id,
}
err = s.stockRepo.CreateLogTx(tx, stockLog)
if err != nil {
return nil, err
}
}
}

View File

@ -273,20 +273,6 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb
}
defer tx.Rollback()
// Reverse stock for old items (add back what was previously subtracted),
// 按物料 ID 升序固定加锁顺序。
oldItemIDs := make([]int64, len(oldOrder.Items))
for i := range oldOrder.Items {
oldItemIDs[i] = oldOrder.Items[i].MaterialID
}
for _, idx := range materialSortedIndexes(oldItemIDs) {
item := oldOrder.Items[idx]
err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil {
return nil, err
}
}
// Delete old stock logs
err = s.stockRepo.DeleteLogsTx(tx, "outbound", id)
if err != nil {
@ -302,34 +288,57 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb
return nil, err
}
// Deduct stock for new items (conditional, ordered) and create logs
newItemIDs := make([]int64, len(req.Items))
for i := range req.Items {
newItemIDs[i] = req.Items[i].MaterialID
// 旧明细(冲销)与新明细(扣减)合并后,按物料 ID 统一升序处理,逐物料先冲销旧量、
// 再条件扣减新量。两个并发的 Update 即便交换物料(tx1 旧={5}→新={3}、tx2 旧={3}→新={5}),
// 也会以相同的全局顺序加锁,不会反过来锁住彼此持有的行造成死锁。
oldQtyByMaterial := make(map[int64]int)
involved := make(map[int64]bool)
for _, item := range oldOrder.Items {
oldQtyByMaterial[item.MaterialID] += item.Quantity
involved[item.MaterialID] = true
}
for _, idx := range materialSortedIndexes(newItemIDs) {
item := req.Items[idx]
err = s.stockRepo.DeductStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil {
if errors.Is(err, repository.ErrInsufficientStock) {
return nil, ErrInsufficientStock
newItemIndexes := make(map[int64][]int)
for i := range req.Items {
materialID := req.Items[i].MaterialID
newItemIndexes[materialID] = append(newItemIndexes[materialID], i)
involved[materialID] = true
}
materialIDs := make([]int64, 0, len(involved))
for materialID := range involved {
materialIDs = append(materialIDs, materialID)
}
for _, idx := range materialSortedIndexes(materialIDs) {
materialID := materialIDs[idx]
if qty := oldQtyByMaterial[materialID]; qty > 0 {
err = s.stockRepo.UpdateStockTx(tx, scope, materialID, decimal.NewFromInt(int64(qty)))
if err != nil {
return nil, err
}
return nil, err
}
for _, i := range newItemIndexes[materialID] {
item := req.Items[i]
err = s.stockRepo.DeductStockTx(tx, scope, materialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil {
if errors.Is(err, repository.ErrInsufficientStock) {
return nil, ErrInsufficientStock
}
return nil, err
}
stockLog := &model.StockLog{
OrganizationID: scope.OrganizationID,
WarehouseID: scope.WarehouseID,
LocationID: scope.LocationID,
MaterialID: item.MaterialID,
Type: "out",
Quantity: item.Quantity,
RelatedOrderType: "outbound",
RelatedOrderID: id,
}
err = s.stockRepo.CreateLogTx(tx, stockLog)
if err != nil {
return nil, err
stockLog := &model.StockLog{
OrganizationID: scope.OrganizationID,
WarehouseID: scope.WarehouseID,
LocationID: scope.LocationID,
MaterialID: materialID,
Type: "out",
Quantity: item.Quantity,
RelatedOrderType: "outbound",
RelatedOrderID: id,
}
err = s.stockRepo.CreateLogTx(tx, stockLog)
if err != nil {
return nil, err
}
}
}

View File

@ -112,13 +112,13 @@ func TestOutboundUpdateDoesNotCountRestoredStockTwice(t *testing.T) {
// 预检:当前 2 + 旧单已扣 2 = 4,够新单的 4。
mock.ExpectQuery("SELECT COALESCE\\(SUM\\(on_hand\\), 0\\) FROM inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
mock.ExpectBegin()
// 先把旧单扣的加回。
mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("DELETE FROM stock_logs").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("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(13, 1))
// 新单扣 4:加回后余额不足,条件扣减影响 0 行 → 回滚。
// 先冲销旧单扣的 2(按物料 ID 排序后,冲销与扣减在同一物料上连续进行)。
mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
// 新单扣 4:冲销后余额仍不足,条件扣减影响 0 行 → 回滚。
mock.ExpectExec("UPDATE inventory_balances SET on_hand = on_hand").WithArgs("4", int64(1), int64(10), int64(20), int64(7), "4").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectRollback()
req := &model.UpdateOutboundRequest{Receiver: "receiver"}