bugs修复
This commit is contained in:
parent
e1401532f1
commit
d62d6777d1
|
|
@ -405,6 +405,14 @@
|
||||||
- 预警以可用库存而非账面库存判断。
|
- 预警以可用库存而非账面库存判断。
|
||||||
- 查询支持组织、仓库、分类、物料类型和处理状态筛选。
|
- 查询支持组织、仓库、分类、物料类型和处理状态筛选。
|
||||||
|
|
||||||
|
#### 当前实现记录(2026-09-23)
|
||||||
|
|
||||||
|
- “库存管理”菜单下保持“库存查询”“入库管理”“出库管理”三个入口。
|
||||||
|
- 仓库和库位基础数据已可在“库存查询 → 仓库设置”中维护,但多仓库和多库位业务流程尚未完全实现。
|
||||||
|
- 系统目前仍主要使用默认仓库 `DEFAULT` 和主库位 `MAIN` 处理入库、出库及库存相关数据。
|
||||||
|
- 待实现:在“库存查询”页面的“仓库设置”旁增加“切换仓库”入口,将所选仓库作为全局当前仓库,并统一应用到库存查询、库存预警、库存流水、入库管理和出库管理。
|
||||||
|
- 待进一步设计:多库位场景下入库、出库的库位选择规则,以及库存查询按库位筛选和展示的交互。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5.6 库存变动管理
|
### 5.6 库存变动管理
|
||||||
|
|
|
||||||
50
frontend/src/components/business/BasePagination.test.js
Normal file
50
frontend/src/components/business/BasePagination.test.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BasePagination from './BasePagination.vue'
|
||||||
|
|
||||||
|
function mountPagination(props) {
|
||||||
|
return mount(BasePagination, {
|
||||||
|
props,
|
||||||
|
global: { plugins: [ElementPlus] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function infoText(wrapper) {
|
||||||
|
return wrapper.find('.page-info').text()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('分页文案', () => {
|
||||||
|
test('中间页显示当前区间', () => {
|
||||||
|
const wrapper = mountPagination({ currentPage: 2, pageSize: 20, total: 41 })
|
||||||
|
expect(infoText(wrapper)).toBe('显示 21-40 条,共 41 条')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('末页不足一页时按实际条数收尾', () => {
|
||||||
|
const wrapper = mountPagination({ currentPage: 3, pageSize: 20, total: 41 })
|
||||||
|
expect(infoText(wrapper)).toBe('显示 41-41 条,共 41 条')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空列表显示 0-0,不显示 1-0', () => {
|
||||||
|
const wrapper = mountPagination({ currentPage: 1, pageSize: 20, total: 0 })
|
||||||
|
expect(infoText(wrapper)).toBe('显示 0-0 条,共 0 条')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删掉末页最后一条:total 已经缩到 40,父组件的 currentPage 还停在 3,
|
||||||
|
// 文案得按收敛后的第 2 页算,不能出现「显示 41-40 条」
|
||||||
|
test('total 缩减导致当前页超界时按收敛后的页码算', async () => {
|
||||||
|
const wrapper = mountPagination({ currentPage: 3, pageSize: 20, total: 41 })
|
||||||
|
await wrapper.setProps({ total: 40 })
|
||||||
|
expect(infoText(wrapper)).toBe('显示 21-40 条,共 40 条')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('当前页远超总页数时收敛到末页', () => {
|
||||||
|
const wrapper = mountPagination({ currentPage: 99, pageSize: 20, total: 41 })
|
||||||
|
expect(infoText(wrapper)).toBe('显示 41-41 条,共 41 条')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pageSize 缺省时按 10 兜底,不出现 NaN', () => {
|
||||||
|
const wrapper = mountPagination({ currentPage: 1, total: 5 })
|
||||||
|
expect(infoText(wrapper)).toBe('显示 1-5 条,共 5 条')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -28,9 +28,20 @@ const currentPage = computed({
|
||||||
set: (val) => emit('update:currentPage', val)
|
set: (val) => emit('update:currentPage', val)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// el-pagination 的 pageSize 缺省是 10,这里跟着兜底,免得算出 NaN
|
||||||
|
const pageSize = computed(() => props.pageSize || 10)
|
||||||
|
// 总页数至少 1,和 el-pagination 内部 pageCountBridge 的算法保持一致
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil((props.total || 0) / pageSize.value)))
|
||||||
|
// 页码可能超出总页数:删掉末页最后一条时父组件还没把 currentPage 收敛下来。
|
||||||
|
// el-pagination 自己会收敛并 emit change 让父组件重拉,但那之间会闪一帧倒挂文案
|
||||||
|
// (「显示 41-40 条」),父组件那次请求失败的话更是永久留在页面上。
|
||||||
|
// 这里按收敛后的页码算区间,让文案和 el-pagination 实际渲染出的页码一致。
|
||||||
|
const safePage = computed(() => Math.min(Math.max(1, props.currentPage || 1), totalPages.value))
|
||||||
|
|
||||||
const pageInfo = computed(() => {
|
const pageInfo = computed(() => {
|
||||||
const start = (props.currentPage - 1) * props.pageSize + 1
|
if (!props.total) return '显示 0-0 条,共 0 条'
|
||||||
const end = Math.min(props.currentPage * props.pageSize, props.total)
|
const start = (safePage.value - 1) * pageSize.value + 1
|
||||||
|
const end = Math.min(safePage.value * pageSize.value, props.total)
|
||||||
return `显示 ${start}-${end} 条,共 ${props.total} 条`
|
return `显示 ${start}-${end} 条,共 ${props.total} 条`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,22 @@ func (r *StockRepo) GetStock(materialID int64) (decimal.Decimal, error) {
|
||||||
return stock, err
|
return stock, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDefaultStock reads the balance that legacy outbound orders actually deduct.
|
||||||
|
func (r *StockRepo) GetDefaultStock(materialID int64) (decimal.Decimal, error) {
|
||||||
|
query := `SELECT ib.on_hand
|
||||||
|
FROM inventory_balances ib
|
||||||
|
JOIN organizations o ON o.id = ib.organization_id AND o.code = 'DEFAULT'
|
||||||
|
JOIN warehouses w ON w.id = ib.warehouse_id AND w.code = 'DEFAULT'
|
||||||
|
JOIN warehouse_locations l ON l.id = ib.location_id AND l.code = 'MAIN'
|
||||||
|
WHERE ib.material_id = ?`
|
||||||
|
var stock decimal.Decimal
|
||||||
|
err := database.DB.QueryRow(query, materialID).Scan(&stock)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return decimal.Zero, nil
|
||||||
|
}
|
||||||
|
return stock, err
|
||||||
|
}
|
||||||
|
|
||||||
// GetStockForUpdateTx locks the legacy default location balance used by current inbound/outbound flows.
|
// GetStockForUpdateTx locks the legacy default location balance used by current inbound/outbound flows.
|
||||||
func (r *StockRepo) GetStockForUpdateTx(tx *sql.Tx, materialID int64) (decimal.Decimal, error) {
|
func (r *StockRepo) GetStockForUpdateTx(tx *sql.Tx, materialID int64) (decimal.Decimal, error) {
|
||||||
query := `SELECT ib.on_hand
|
query := `SELECT ib.on_hand
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator stri
|
||||||
return nil, ErrMaterialObsolete
|
return nil, ErrMaterialObsolete
|
||||||
}
|
}
|
||||||
|
|
||||||
currentStock, err := s.stockRepo.GetStock(item.MaterialID)
|
currentStock, err := s.stockRepo.GetDefaultStock(item.MaterialID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -71,11 +71,7 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator stri
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer tx.Rollback()
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Re-check stock with row lock (SELECT FOR UPDATE) to prevent overselling
|
// Re-check stock with row lock (SELECT FOR UPDATE) to prevent overselling
|
||||||
for i := range order.Items {
|
for i := range order.Items {
|
||||||
|
|
@ -159,11 +155,7 @@ func (s *OutboundService) Delete(id int64) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer tx.Rollback()
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Reverse stock for each item (add back) within transaction
|
// Reverse stock for each item (add back) within transaction
|
||||||
for _, item := range order.Items {
|
for _, item := range order.Items {
|
||||||
|
|
@ -217,7 +209,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope
|
||||||
return nil, ErrMaterialObsolete
|
return nil, ErrMaterialObsolete
|
||||||
}
|
}
|
||||||
|
|
||||||
currentStock, err := s.stockRepo.GetStock(item.MaterialID)
|
currentStock, err := s.stockRepo.GetDefaultStock(item.MaterialID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -240,11 +232,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer tx.Rollback()
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Reverse stock for old items (add back what was previously subtracted)
|
// Reverse stock for old items (add back what was previously subtracted)
|
||||||
for _, item := range oldOrder.Items {
|
for _, item := range oldOrder.Items {
|
||||||
|
|
@ -260,15 +248,8 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
oldQty := 0
|
// Old quantities have already been restored above.
|
||||||
for _, oldItem := range oldOrder.Items {
|
if lockedStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
|
||||||
if oldItem.MaterialID == item.MaterialID {
|
|
||||||
oldQty = oldItem.Quantity
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
effectiveStock := lockedStock.Add(decimal.NewFromInt(int64(oldQty)))
|
|
||||||
if effectiveStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
|
|
||||||
err = ErrInsufficientStock
|
err = ErrInsufficientStock
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
116
internal/service/outbound_test.go
Normal file
116
internal/service/outbound_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/wmms/wmms/internal/model"
|
||||||
|
"github.com/wmms/wmms/pkg/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func outboundMock(t *testing.T) sqlmock.Sqlmock {
|
||||||
|
t.Helper()
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
previous := database.DB
|
||||||
|
database.DB = db
|
||||||
|
t.Cleanup(func() { database.DB = previous; db.Close() })
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) {
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(id).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||||||
|
AddRow(id, 1, "M-1", "Material", nil, "", nil, 1, 1, "pc", "active", 1, time.Now(), time.Now()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOutboundRequest() *model.CreateOutboundRequest {
|
||||||
|
req := &model.CreateOutboundRequest{Receiver: "receiver"}
|
||||||
|
req.Items = append(req.Items, struct {
|
||||||
|
MaterialID int64 `json:"material_id" binding:"required"`
|
||||||
|
Quantity int `json:"quantity" binding:"required,gt=0"`
|
||||||
|
}{MaterialID: 7, Quantity: 2})
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutboundCreateRollsBackWhenLockedStockRunsOut(t *testing.T) {
|
||||||
|
mock := outboundMock(t)
|
||||||
|
expectActiveMaterial(mock, 7)
|
||||||
|
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("1"))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
_, err := NewOutboundService().Create(createOutboundRequest(), "operator")
|
||||||
|
if !errors.Is(err, ErrInsufficientStock) {
|
||||||
|
t.Fatalf("got %v, want insufficient stock", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutboundCreateChecksDefaultLocationBeforeTransaction(t *testing.T) {
|
||||||
|
mock := outboundMock(t)
|
||||||
|
expectActiveMaterial(mock, 7)
|
||||||
|
mock.ExpectQuery("SELECT ib.on_hand.*FROM inventory_balances").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("1"))
|
||||||
|
_, err := NewOutboundService().Create(createOutboundRequest(), "operator")
|
||||||
|
if !errors.Is(err, ErrInsufficientStock) {
|
||||||
|
t.Fatalf("got %v, want insufficient stock", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutboundCreateRollsBackWhenMaterialReadFailsAfterOrderInsert(t *testing.T) {
|
||||||
|
mock := outboundMock(t)
|
||||||
|
expectActiveMaterial(mock, 7)
|
||||||
|
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
|
||||||
|
mock.ExpectExec("INSERT INTO outbound_orders").WillReturnResult(sqlmock.NewResult(11, 1))
|
||||||
|
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(12, 1))
|
||||||
|
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(7)).WillReturnError(errors.New("material read failed"))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
_, err := NewOutboundService().Create(createOutboundRequest(), "operator")
|
||||||
|
if err == nil || err.Error() != "material read failed" {
|
||||||
|
t.Fatalf("got %v, want material read failure", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutboundUpdateDoesNotCountRestoredStockTwice(t *testing.T) {
|
||||||
|
mock := outboundMock(t)
|
||||||
|
now := time.Now()
|
||||||
|
mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11)).WillReturnRows(
|
||||||
|
sqlmock.NewRows([]string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "created_at", "updated_at"}).AddRow(11, "OUT-11", "operator", now, "operator", "receiver", "", now, now))
|
||||||
|
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"))
|
||||||
|
expectActiveMaterial(mock, 7)
|
||||||
|
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("3"))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
req := &model.UpdateOutboundRequest{Receiver: "receiver"}
|
||||||
|
req.Items = append(req.Items, struct {
|
||||||
|
MaterialID int64 `json:"material_id" binding:"required"`
|
||||||
|
Quantity int `json:"quantity" binding:"required,gt=0"`
|
||||||
|
}{MaterialID: 7, Quantity: 4})
|
||||||
|
_, err := NewOutboundService().Update(11, req, "operator")
|
||||||
|
if !errors.Is(err, ErrInsufficientStock) {
|
||||||
|
t.Fatalf("got %v, want insufficient stock", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user