fix: 修正普通审批 scope、出库更新加锁顺序与预警物料归属校验
This commit is contained in:
parent
e3cfe88d4e
commit
ee18a2e56f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,13 +104,14 @@ func (s *ApplicationService) Approve(organizationID, id int64, approverID int64,
|
|||
return ErrApplicationNotPending
|
||||
}
|
||||
|
||||
// 库存维度由服务端按当前公司解析;审批自动出库会扣减本公司库存。
|
||||
// Auto outbound if requested
|
||||
if autoOutbound {
|
||||
// 只有自动出库才会改变库存,才需要解析本公司启用的仓库、库位并做库存检查。
|
||||
// 普通审批(不扣库存)沿用已有规则:只要求公司授权和相关物料合法,不要求仓库配置。
|
||||
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 {
|
||||
|
|
@ -121,8 +122,6 @@ func (s *ApplicationService) Approve(organizationID, id int64, approverID int64,
|
|||
}
|
||||
}
|
||||
|
||||
// Auto outbound if requested
|
||||
if autoOutbound {
|
||||
// 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)
|
||||
|
|
|
|||
31
internal/service/application_test.go
Normal file
31
internal/service/application_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,13 +257,43 @@ 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)
|
||||
// 旧明细(冲销)与新明细(补货)合并后按物料 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, item.MaterialID, decimal.NewFromInt(int64(item.Quantity)))
|
||||
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
|
||||
}
|
||||
|
|
@ -283,7 +301,7 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou
|
|||
// 阈值随明细一起更新,nil 表示不改动已存的值,避免编辑旧入库单时把刚调好的
|
||||
// 阈值覆盖回旧数;启用状态同理,nil 表示不改动。
|
||||
if item.WarningStock != nil {
|
||||
err = s.stockRepo.UpdateWarningStockTx(tx, scope, item.MaterialID, *item.WarningStock,
|
||||
err = s.stockRepo.UpdateWarningStockTx(tx, scope, materialID, *item.WarningStock,
|
||||
resolveWarningEnabled(item.WarningEnabled))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -294,7 +312,7 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou
|
|||
OrganizationID: scope.OrganizationID,
|
||||
WarehouseID: scope.WarehouseID,
|
||||
LocationID: scope.LocationID,
|
||||
MaterialID: item.MaterialID,
|
||||
MaterialID: materialID,
|
||||
Type: "in",
|
||||
Quantity: item.Quantity,
|
||||
RelatedOrderType: "inbound",
|
||||
|
|
@ -305,6 +323,7 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
|
|
|
|||
|
|
@ -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,14 +288,36 @@ 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)))
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
|
|
@ -321,7 +329,7 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb
|
|||
OrganizationID: scope.OrganizationID,
|
||||
WarehouseID: scope.WarehouseID,
|
||||
LocationID: scope.LocationID,
|
||||
MaterialID: item.MaterialID,
|
||||
MaterialID: materialID,
|
||||
Type: "out",
|
||||
Quantity: item.Quantity,
|
||||
RelatedOrderType: "outbound",
|
||||
|
|
@ -332,6 +340,7 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user