完善出库和入库逻辑
This commit is contained in:
parent
d9da2ed672
commit
332ac1ff7a
10
docs/api.md
10
docs/api.md
|
|
@ -421,6 +421,7 @@
|
|||
{
|
||||
"handler": "张三",
|
||||
"supplier": "ABC 钢材",
|
||||
"remark": "到货验收通过",
|
||||
"items": [
|
||||
{ "material_id": 1, "quantity": 500, "warning_stock": 100, "warning_enabled": true },
|
||||
{ "material_id": 2, "quantity": 200, "warning_stock": 0, "warning_enabled": false },
|
||||
|
|
@ -433,6 +434,7 @@
|
|||
|----------|--------|------|--------------------|
|
||||
| handler | string | 是 | 经手人 |
|
||||
| supplier | string | 是 | 供应商 |
|
||||
| remark | string | 否 | 备注 |
|
||||
| items | array | 是 | 明细,至少 1 行 |
|
||||
|
||||
**明细字段:**
|
||||
|
|
@ -448,7 +450,7 @@
|
|||
>
|
||||
> 关闭预警而想保留原阈值时,把该物料当前的阈值连同 `warning_enabled: false` 一起传回即可(前端表单正是这么做的)。
|
||||
>
|
||||
> 本接口**不支持备注字段**。前端即便提交了 `remark`,服务端也会忽略。
|
||||
`inbound_no` 由服务端按 `IN-` 加 8 位订单 ID 自动生成;`operator` 为创建该单据的当前登录用户。二者均由服务端维护,不能由请求体指定。
|
||||
|
||||
**响应:** `201 Created`,`data` 为创建后的入库单,结构同 `GET /inbound/:id`。
|
||||
|
||||
|
|
@ -569,7 +571,6 @@
|
|||
{
|
||||
"id": 1,
|
||||
"outbound_date": "2026-01-01T08:00:00Z",
|
||||
"handler": "李四",
|
||||
"receiver": "装配车间",
|
||||
"status": "completed",
|
||||
"created_at": "2026-01-01T08:00:00Z",
|
||||
|
|
@ -644,6 +645,7 @@
|
|||
{
|
||||
"handler": "李四",
|
||||
"receiver": "装配车间",
|
||||
"remark": "急用",
|
||||
"items": [
|
||||
{ "material_id": 1, "quantity": 100 },
|
||||
{ "material_id": 2, "quantity": 50 }
|
||||
|
|
@ -653,8 +655,8 @@
|
|||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|----------|--------|------|------------------|
|
||||
| handler | string | 是 | 经手人 |
|
||||
| receiver | string | 是 | 领用人 |
|
||||
| remark | string | 否 | 备注 |
|
||||
| items | array | 是 | 明细,至少 1 行 |
|
||||
|
||||
**明细字段:**
|
||||
|
|
@ -664,6 +666,8 @@
|
|||
| material_id | integer | 是 | 物料 ID |
|
||||
| quantity | integer | 是 | 出库数量,必须大于 0 |
|
||||
|
||||
`outbound_no` 由服务端按 `OUT-` 加 8 位订单 ID 自动生成;`operator` 与 `handler` 均由服务端设置为当前登录用户名,不能由请求体指定。
|
||||
|
||||
**响应:** `201 Created`,`data` 为创建后的出库单,结构同 `GET /outbound/:id`。
|
||||
|
||||
**状态码:**
|
||||
|
|
|
|||
|
|
@ -39,9 +39,6 @@
|
|||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑出库单' : '新增出库单'" width="900px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="操作人">
|
||||
<el-input v-model="form.handler" placeholder="请输入操作人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="接收方">
|
||||
<el-input v-model="form.receiver" placeholder="请输入接收方" />
|
||||
</el-form-item>
|
||||
|
|
@ -131,7 +128,6 @@ const isEdit = ref(false)
|
|||
const editId = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
handler: '',
|
||||
receiver: '',
|
||||
remark: '',
|
||||
items: [{ material_id: null, materialSearchText: '', quantity: 1 }]
|
||||
|
|
@ -157,7 +153,7 @@ function handlePageChange(page) {
|
|||
function showAddModal() {
|
||||
isEdit.value = false
|
||||
editId.value = null
|
||||
Object.assign(form, { handler: '', receiver: '', remark: '', items: [{ material_id: null, materialSearchText: '', quantity: 1 }] })
|
||||
Object.assign(form, { receiver: '', remark: '', items: [{ material_id: null, materialSearchText: '', quantity: 1 }] })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +163,6 @@ async function showEditModal(row) {
|
|||
try {
|
||||
const res = await outboundStore.fetchOutboundDetail(row.id)
|
||||
const order = res.data || res
|
||||
form.handler = order.handler || ''
|
||||
form.receiver = order.receiver || ''
|
||||
form.remark = order.remark || ''
|
||||
form.items = order.items && order.items.length > 0
|
||||
|
|
@ -245,7 +240,6 @@ async function handleSave() {
|
|||
return
|
||||
}
|
||||
const data = {
|
||||
handler: form.handler,
|
||||
receiver: form.receiver,
|
||||
remark: form.remark,
|
||||
items: validItems
|
||||
|
|
|
|||
|
|
@ -458,7 +458,11 @@ func (h *Handler) CreateInbound(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
order, err := h.inboundSvc.Create(&req)
|
||||
operator := middleware.GetCurrentUsername(c)
|
||||
if operator == "" {
|
||||
operator = "系统"
|
||||
}
|
||||
order, err := h.inboundSvc.Create(&req, operator)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrMaterialNotFound) {
|
||||
response.BadRequest(c, "material not found")
|
||||
|
|
@ -589,7 +593,11 @@ func (h *Handler) CreateOutbound(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
order, err := h.outboundSvc.Create(&req)
|
||||
operator := middleware.GetCurrentUsername(c)
|
||||
if operator == "" {
|
||||
operator = "系统"
|
||||
}
|
||||
order, err := h.outboundSvc.Create(&req, operator)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrMaterialNotFound) {
|
||||
response.BadRequest(c, "material not found")
|
||||
|
|
@ -635,7 +643,11 @@ func (h *Handler) UpdateOutbound(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
order, err := h.outboundSvc.Update(id, &req)
|
||||
operator := middleware.GetCurrentUsername(c)
|
||||
if operator == "" {
|
||||
operator = "系统"
|
||||
}
|
||||
order, err := h.outboundSvc.Update(id, &req, operator)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrMaterialNotFound) {
|
||||
response.BadRequest(c, "material not found")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
|
@ -55,9 +56,12 @@ type ApplyItem struct {
|
|||
// InboundOrder represents an inbound order
|
||||
type InboundOrder struct {
|
||||
ID int64 `json:"id"`
|
||||
InboundNo string `json:"inbound_no"`
|
||||
Operator string `json:"operator"`
|
||||
InboundDate time.Time `json:"inbound_date"`
|
||||
Handler string `json:"handler"`
|
||||
Supplier string `json:"supplier"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
|
|
@ -77,9 +81,12 @@ type InboundItem struct {
|
|||
// OutboundOrder represents an outbound order
|
||||
type OutboundOrder struct {
|
||||
ID int64 `json:"id"`
|
||||
OutboundNo string `json:"outbound_no"`
|
||||
Operator string `json:"operator"`
|
||||
OutboundDate time.Time `json:"outbound_date"`
|
||||
Handler string `json:"handler"`
|
||||
Receiver string `json:"receiver"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
|
|
@ -96,6 +103,13 @@ type OutboundItem struct {
|
|||
MaterialName string `json:"material_name,omitempty"`
|
||||
}
|
||||
|
||||
// OrderNo returns the stable display number used by inbound and outbound
|
||||
// documents. It derives from the database ID, so it remains unique and does
|
||||
// not change when the document is edited.
|
||||
func OrderNo(prefix string, id int64) string {
|
||||
return fmt.Sprintf("%s-%08d", prefix, id)
|
||||
}
|
||||
|
||||
// StockLog represents a stock change log
|
||||
type StockLog struct {
|
||||
ID int64 `json:"id"`
|
||||
|
|
@ -216,6 +230,7 @@ type UpdateMaterialRequest struct {
|
|||
type CreateInboundRequest struct {
|
||||
Handler string `json:"handler" binding:"required"`
|
||||
Supplier string `json:"supplier" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
Items []struct {
|
||||
MaterialID int64 `json:"material_id" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,gt=0"`
|
||||
|
|
@ -229,8 +244,11 @@ type CreateInboundRequest struct {
|
|||
|
||||
// CreateOutboundRequest represents the request to create an outbound order
|
||||
type CreateOutboundRequest struct {
|
||||
Handler string `json:"handler" binding:"required"`
|
||||
// Handler is retained for API compatibility but is ignored. The server
|
||||
// always records the authenticated user's username as the operator.
|
||||
Handler string `json:"handler"`
|
||||
Receiver string `json:"receiver" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
Items []struct {
|
||||
MaterialID int64 `json:"material_id" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,gt=0"`
|
||||
|
|
@ -241,6 +259,7 @@ type CreateOutboundRequest struct {
|
|||
type UpdateInboundRequest struct {
|
||||
Handler string `json:"handler"`
|
||||
Supplier string `json:"supplier"`
|
||||
Remark string `json:"remark"`
|
||||
Items []struct {
|
||||
MaterialID int64 `json:"material_id" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,gt=0"`
|
||||
|
|
@ -255,7 +274,9 @@ type UpdateInboundRequest struct {
|
|||
// UpdateOutboundRequest represents the request to update an outbound order
|
||||
type UpdateOutboundRequest struct {
|
||||
Handler string `json:"handler"`
|
||||
Operator string `json:"-"`
|
||||
Receiver string `json:"receiver"`
|
||||
Remark string `json:"remark"`
|
||||
Items []struct {
|
||||
MaterialID int64 `json:"material_id" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,gt=0"`
|
||||
|
|
|
|||
69
internal/model/order_fields_test.go
Normal file
69
internal/model/order_fields_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
func TestOrderRequestsAndResponsesPreserveFrontendFields(t *testing.T) {
|
||||
inboundRequest := CreateInboundRequest{}
|
||||
if err := json.Unmarshal([]byte(`{"handler":"张三","supplier":"供应商","remark":"到货验收通过","items":[{"material_id":1,"quantity":2}]}`), &inboundRequest); err != nil {
|
||||
t.Fatalf("unmarshal inbound request: %v", err)
|
||||
}
|
||||
if inboundRequest.Remark != "到货验收通过" {
|
||||
t.Fatalf("inbound remark = %q, want %q", inboundRequest.Remark, "到货验收通过")
|
||||
}
|
||||
|
||||
outboundRequest := CreateOutboundRequest{}
|
||||
if err := json.Unmarshal([]byte(`{"handler":"李四","receiver":"装配车间","remark":"急用","items":[{"material_id":1,"quantity":2}]}`), &outboundRequest); err != nil {
|
||||
t.Fatalf("unmarshal outbound request: %v", err)
|
||||
}
|
||||
if outboundRequest.Remark != "急用" {
|
||||
t.Fatalf("outbound remark = %q, want %q", outboundRequest.Remark, "急用")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(struct {
|
||||
Inbound InboundOrder `json:"inbound"`
|
||||
Outbound OutboundOrder `json:"outbound"`
|
||||
}{
|
||||
Inbound: InboundOrder{InboundNo: "IN-20260920-0001", Operator: "管理员", Remark: "到货验收通过"},
|
||||
Outbound: OutboundOrder{OutboundNo: "OUT-20260920-0001", Operator: "管理员", Remark: "急用"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal orders: %v", err)
|
||||
}
|
||||
|
||||
for _, field := range []string{"inbound_no", "outbound_no", "operator", "remark"} {
|
||||
if !containsJSONField(payload, field) {
|
||||
t.Fatalf("order response omits %q: %s", field, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundRequestDoesNotRequireClientProvidedHandler(t *testing.T) {
|
||||
var request CreateOutboundRequest
|
||||
if err := json.Unmarshal([]byte(`{"receiver":"装配车间","items":[{"material_id":1,"quantity":2}]}`), &request); err != nil {
|
||||
t.Fatalf("unmarshal outbound request: %v", err)
|
||||
}
|
||||
|
||||
validate := validator.New()
|
||||
validate.SetTagName("binding")
|
||||
if err := validate.Struct(request); err != nil {
|
||||
t.Fatalf("outbound request without a client handler should be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsJSONField(payload []byte, field string) bool {
|
||||
var decoded map[string]map[string]any
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, order := range decoded {
|
||||
if _, ok := order[field]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -195,8 +195,8 @@ func (r *ApplicationRepository) GetByIDWithDetails(id int64) (*model.MaterialApp
|
|||
}
|
||||
|
||||
func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder) error {
|
||||
query := `INSERT INTO outbound_orders (outbound_date, handler, receiver, status) VALUES (NOW(), ?, ?, ?)`
|
||||
result, err := database.DB.Exec(query, order.Handler, order.Receiver, order.Status)
|
||||
query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, status) VALUES (NOW(), ?, ?, ?, ?)`
|
||||
result, err := database.DB.Exec(query, order.Operator, order.Handler, order.Receiver, order.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -205,12 +205,16 @@ func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder)
|
|||
return err
|
||||
}
|
||||
order.ID = id
|
||||
order.OutboundNo = model.OrderNo("OUT", id)
|
||||
if _, err := database.DB.Exec(`UPDATE outbound_orders SET outbound_no = ? WHERE id = ?`, order.OutboundNo, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ApplicationRepository) CreateOutboundOrderTx(tx *sql.Tx, order *model.OutboundOrder) error {
|
||||
query := `INSERT INTO outbound_orders (outbound_date, handler, receiver, status) VALUES (NOW(), ?, ?, ?)`
|
||||
result, err := tx.Exec(query, order.Handler, order.Receiver, order.Status)
|
||||
query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, status) VALUES (NOW(), ?, ?, ?, ?)`
|
||||
result, err := tx.Exec(query, order.Operator, order.Handler, order.Receiver, order.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -219,6 +223,10 @@ func (r *ApplicationRepository) CreateOutboundOrderTx(tx *sql.Tx, order *model.O
|
|||
return err
|
||||
}
|
||||
order.ID = id
|
||||
order.OutboundNo = model.OrderNo("OUT", id)
|
||||
if _, err := tx.Exec(`UPDATE outbound_orders SET outbound_no = ? WHERE id = ?`, order.OutboundNo, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ func (r *InboundRepo) CreateTx(tx *sql.Tx, order *model.InboundOrder) error {
|
|||
defer tx.Rollback()
|
||||
}
|
||||
|
||||
query := `INSERT INTO inbound_orders (inbound_date, handler, supplier) VALUES (?, ?, ?)`
|
||||
result, err := tx.Exec(query, order.InboundDate, order.Handler, order.Supplier)
|
||||
query := `INSERT INTO inbound_orders (inbound_date, operator, handler, supplier, remark) VALUES (?, ?, ?, ?, ?)`
|
||||
result, err := tx.Exec(query, order.InboundDate, order.Operator, order.Handler, order.Supplier, order.Remark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -42,6 +42,10 @@ func (r *InboundRepo) CreateTx(tx *sql.Tx, order *model.InboundOrder) error {
|
|||
return err
|
||||
}
|
||||
order.ID = orderID
|
||||
order.InboundNo = model.OrderNo("IN", orderID)
|
||||
if _, err = tx.Exec(`UPDATE inbound_orders SET inbound_no = ? WHERE id = ?`, order.InboundNo, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range order.Items {
|
||||
item := &order.Items[i]
|
||||
|
|
@ -66,8 +70,8 @@ func (r *InboundRepo) CreateTx(tx *sql.Tx, order *model.InboundOrder) error {
|
|||
|
||||
func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) {
|
||||
order := &model.InboundOrder{}
|
||||
query := `SELECT id, inbound_date, handler, supplier, created_at, updated_at FROM inbound_orders WHERE id = ?`
|
||||
err := database.DB.QueryRow(query, id).Scan(&order.ID, &order.InboundDate, &order.Handler, &order.Supplier, &order.CreatedAt, &order.UpdatedAt)
|
||||
query := `SELECT id, COALESCE(inbound_no, CONCAT('IN-', LPAD(id, 8, '0'))), COALESCE(operator, ''), inbound_date, handler, supplier, COALESCE(remark, ''), created_at, updated_at FROM inbound_orders WHERE id = ?`
|
||||
err := database.DB.QueryRow(query, id).Scan(&order.ID, &order.InboundNo, &order.Operator, &order.InboundDate, &order.Handler, &order.Supplier, &order.Remark, &order.CreatedAt, &order.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -111,7 +115,7 @@ func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrd
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := `SELECT id, inbound_date, handler, supplier, 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, ''), created_at, updated_at FROM inbound_orders WHERE 1=1`
|
||||
var args []interface{}
|
||||
|
||||
if search != "" {
|
||||
|
|
@ -131,7 +135,7 @@ func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrd
|
|||
var orders []model.InboundOrder
|
||||
for rows.Next() {
|
||||
var order model.InboundOrder
|
||||
err := rows.Scan(&order.ID, &order.InboundDate, &order.Handler, &order.Supplier, &order.CreatedAt, &order.UpdatedAt)
|
||||
err := rows.Scan(&order.ID, &order.InboundNo, &order.Operator, &order.InboundDate, &order.Handler, &order.Supplier, &order.Remark, &order.CreatedAt, &order.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
@ -214,8 +218,8 @@ func (r *InboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateInboundReq
|
|||
}
|
||||
|
||||
// Update order basic fields
|
||||
query := `UPDATE inbound_orders SET handler = ?, supplier = ? WHERE id = ?`
|
||||
_, err := tx.Exec(query, req.Handler, req.Supplier, id)
|
||||
query := `UPDATE inbound_orders SET handler = ?, supplier = ?, remark = ? WHERE id = ?`
|
||||
_, err := tx.Exec(query, req.Handler, req.Supplier, req.Remark, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
74
internal/repository/order_null_fields_test.go
Normal file
74
internal/repository/order_null_fields_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/wmms/wmms/pkg/database"
|
||||
)
|
||||
|
||||
func TestInboundListConvertsLegacyNullDisplayFields(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("open sql mock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
database.DB = nil
|
||||
db.Close()
|
||||
})
|
||||
database.DB = db
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM inbound_orders WHERE 1=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 ?`
|
||||
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(20, 0).WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "inbound_no", "operator", "inbound_date", "handler", "supplier", "remark", "created_at", "updated_at"}).
|
||||
AddRow(42, "IN-00000042", "", now, "张三", "供应商", "", now, now),
|
||||
)
|
||||
|
||||
orders, _, err := NewInboundRepo().List("", 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list inbound orders with legacy NULL fields: %v", err)
|
||||
}
|
||||
if got := orders[0]; got.InboundNo != "IN-00000042" || got.Operator != "" || got.Remark != "" {
|
||||
t.Fatalf("unexpected legacy inbound order: %#v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundListConvertsLegacyNullDisplayFields(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("open sql mock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
database.DB = nil
|
||||
db.Close()
|
||||
})
|
||||
database.DB = db
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM outbound_orders WHERE 1=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 ?`
|
||||
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(20, 0).WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "created_at", "updated_at"}).
|
||||
AddRow(27, "OUT-00000027", "", now, "李四", "装配车间", "", now, now),
|
||||
)
|
||||
|
||||
orders, _, err := NewOutboundRepo().List("", 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list outbound orders with legacy NULL fields: %v", err)
|
||||
}
|
||||
if got := orders[0]; got.OutboundNo != "OUT-00000027" || got.Operator != "" || got.Remark != "" {
|
||||
t.Fatalf("unexpected legacy outbound order: %#v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -32,8 +32,8 @@ func (r *OutboundRepo) CreateTx(tx *sql.Tx, order *model.OutboundOrder) error {
|
|||
defer tx.Rollback()
|
||||
}
|
||||
|
||||
query := `INSERT INTO outbound_orders (outbound_date, handler, receiver) VALUES (?, ?, ?)`
|
||||
result, err := tx.Exec(query, order.OutboundDate, order.Handler, order.Receiver)
|
||||
query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, remark) VALUES (?, ?, ?, ?, ?)`
|
||||
result, err := tx.Exec(query, order.OutboundDate, order.Operator, order.Handler, order.Receiver, order.Remark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -42,6 +42,10 @@ func (r *OutboundRepo) CreateTx(tx *sql.Tx, order *model.OutboundOrder) error {
|
|||
return err
|
||||
}
|
||||
order.ID = orderID
|
||||
order.OutboundNo = model.OrderNo("OUT", orderID)
|
||||
if _, err = tx.Exec(`UPDATE outbound_orders SET outbound_no = ? WHERE id = ?`, order.OutboundNo, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range order.Items {
|
||||
item := &order.Items[i]
|
||||
|
|
@ -66,8 +70,8 @@ func (r *OutboundRepo) CreateTx(tx *sql.Tx, order *model.OutboundOrder) error {
|
|||
|
||||
func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) {
|
||||
order := &model.OutboundOrder{}
|
||||
query := `SELECT id, outbound_date, handler, receiver, created_at, updated_at FROM outbound_orders WHERE id = ?`
|
||||
err := database.DB.QueryRow(query, id).Scan(&order.ID, &order.OutboundDate, &order.Handler, &order.Receiver, &order.CreatedAt, &order.UpdatedAt)
|
||||
query := `SELECT id, COALESCE(outbound_no, CONCAT('OUT-', LPAD(id, 8, '0'))), COALESCE(operator, ''), outbound_date, handler, receiver, COALESCE(remark, ''), created_at, updated_at FROM outbound_orders WHERE id = ?`
|
||||
err := database.DB.QueryRow(query, id).Scan(&order.ID, &order.OutboundNo, &order.Operator, &order.OutboundDate, &order.Handler, &order.Receiver, &order.Remark, &order.CreatedAt, &order.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -111,7 +115,7 @@ func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundO
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := `SELECT id, outbound_date, handler, receiver, 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, ''), created_at, updated_at FROM outbound_orders WHERE 1=1`
|
||||
var args []interface{}
|
||||
|
||||
if search != "" {
|
||||
|
|
@ -131,7 +135,7 @@ func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundO
|
|||
var orders []model.OutboundOrder
|
||||
for rows.Next() {
|
||||
var order model.OutboundOrder
|
||||
err := rows.Scan(&order.ID, &order.OutboundDate, &order.Handler, &order.Receiver, &order.CreatedAt, &order.UpdatedAt)
|
||||
err := rows.Scan(&order.ID, &order.OutboundNo, &order.Operator, &order.OutboundDate, &order.Handler, &order.Receiver, &order.Remark, &order.CreatedAt, &order.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
@ -214,8 +218,8 @@ func (r *OutboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateOutboundR
|
|||
}
|
||||
|
||||
// Update order basic fields
|
||||
query := `UPDATE outbound_orders SET handler = ?, receiver = ? WHERE id = ?`
|
||||
_, err := tx.Exec(query, req.Handler, req.Receiver, id)
|
||||
query := `UPDATE outbound_orders SET operator = ?, handler = ?, receiver = ?, remark = ? WHERE id = ?`
|
||||
_, err := tx.Exec(query, req.Operator, req.Handler, req.Receiver, req.Remark, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ func (s *ApplicationService) Approve(id int64, approverID int64, autoOutbound bo
|
|||
// Create outbound order within transaction
|
||||
order := &model.OutboundOrder{
|
||||
Handler: approverName,
|
||||
Operator: approverName,
|
||||
Receiver: app.Username,
|
||||
Status: "completed",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,11 +31,13 @@ func resolveWarningEnabled(enabled *bool) bool {
|
|||
return *enabled
|
||||
}
|
||||
|
||||
func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.InboundOrder, error) {
|
||||
func (s *InboundService) Create(req *model.CreateInboundRequest, operator string) (*model.InboundOrder, error) {
|
||||
order := &model.InboundOrder{
|
||||
InboundDate: time.Now(),
|
||||
Operator: operator,
|
||||
Handler: req.Handler,
|
||||
Supplier: req.Supplier,
|
||||
Remark: req.Remark,
|
||||
Items: make([]model.InboundItem, len(req.Items)),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,11 +25,13 @@ func NewOutboundService() *OutboundService {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *OutboundService) Create(req *model.CreateOutboundRequest) (*model.OutboundOrder, error) {
|
||||
func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator string) (*model.OutboundOrder, error) {
|
||||
order := &model.OutboundOrder{
|
||||
OutboundDate: time.Now(),
|
||||
Handler: req.Handler,
|
||||
Operator: operator,
|
||||
Handler: operator,
|
||||
Receiver: req.Receiver,
|
||||
Remark: req.Remark,
|
||||
Items: make([]model.OutboundItem, len(req.Items)),
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +186,7 @@ func (s *OutboundService) Delete(id int64) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*model.OutboundOrder, error) {
|
||||
func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, operator string) (*model.OutboundOrder, error) {
|
||||
// Get old order data
|
||||
oldOrder, err := s.outboundRepo.GetByID(id)
|
||||
if err != nil {
|
||||
|
|
@ -265,6 +267,9 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
|
|||
}
|
||||
|
||||
// Update order in repository
|
||||
// 客户端不能指定操作人;编辑也由当前登录用户接管操作人和兼容字段 handler。
|
||||
req.Handler = operator
|
||||
req.Operator = operator
|
||||
err = s.outboundRepo.UpdateTx(tx, id, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
21
scripts/add_order_display_fields.sql
Normal file
21
scripts/add_order_display_fields.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- Apply once to databases created before inbound/outbound display fields existed.
|
||||
-- Existing documents receive a stable number derived from their primary key;
|
||||
-- their operator and remark remain NULL because that historical data was never recorded.
|
||||
|
||||
ALTER TABLE inbound_orders
|
||||
ADD COLUMN inbound_no VARCHAR(32) NULL UNIQUE AFTER location_id,
|
||||
ADD COLUMN operator VARCHAR(100) NULL AFTER inbound_no,
|
||||
ADD COLUMN remark TEXT NULL AFTER supplier;
|
||||
|
||||
UPDATE inbound_orders
|
||||
SET inbound_no = CONCAT('IN-', LPAD(id, 8, '0'))
|
||||
WHERE inbound_no IS NULL;
|
||||
|
||||
ALTER TABLE outbound_orders
|
||||
ADD COLUMN outbound_no VARCHAR(32) NULL UNIQUE AFTER location_id,
|
||||
ADD COLUMN operator VARCHAR(100) NULL AFTER outbound_no,
|
||||
ADD COLUMN remark TEXT NULL AFTER receiver;
|
||||
|
||||
UPDATE outbound_orders
|
||||
SET outbound_no = CONCAT('OUT-', LPAD(id, 8, '0'))
|
||||
WHERE outbound_no IS NULL;
|
||||
|
|
@ -28,9 +28,9 @@ CREATE TABLE warehouse_locations (id BIGINT AUTO_INCREMENT PRIMARY KEY, warehous
|
|||
CREATE TABLE user_warehouses (user_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(user_id,warehouse_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(warehouse_id) REFERENCES warehouses(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE inventory_balances (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, material_id BIGINT NOT NULL, on_hand DECIMAL(20,6) NOT NULL DEFAULT 0, reserved DECIMAL(20,6) NOT NULL DEFAULT 0, locked DECIMAL(20,6) NOT NULL DEFAULT 0, in_transit DECIMAL(20,6) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_inventory_balance_dimension(organization_id,warehouse_id,location_id,material_id), KEY idx_inventory_balances_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE stock_warning_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, material_id BIGINT NOT NULL, minimum_available DECIMAL(20,6) NOT NULL DEFAULT 0, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_stock_warning_rules_dimension(organization_id,warehouse_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE inbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, inbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, supplier VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_inbound_orders_date(inbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE inbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, inbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), inbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, supplier VARCHAR(200), remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_inbound_orders_date(inbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE inbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, inbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_inbound_items_order(inbound_order_id), FOREIGN KEY(inbound_order_id) REFERENCES inbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE outbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, outbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, receiver VARCHAR(100) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_outbound_orders_date(outbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE outbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, outbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), outbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, receiver VARCHAR(100) NOT NULL, remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_outbound_orders_date(outbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE outbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, outbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_outbound_items_order(outbound_order_id), FOREIGN KEY(outbound_order_id) REFERENCES outbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE material_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, operation_type VARCHAR(50) NOT NULL, material_id BIGINT NOT NULL, old_value TEXT, new_value TEXT, operator VARCHAR(100), description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_logs_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE stock_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, type ENUM('in','out') NOT NULL, quantity INT NOT NULL, related_order_type VARCHAR(50) NOT NULL, related_order_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_stock_logs_material(material_id), KEY idx_stock_logs_related_order(related_order_type,related_order_id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
|
|
|||
3
启动项目.bat
3
启动项目.bat
|
|
@ -4,7 +4,8 @@ title Warehouse Management System Launcher
|
|||
cd /d "%~dp0"
|
||||
|
||||
echo Starting backend (8082)...
|
||||
start "Warehouse MMS - Backend" /D "%~dp0" cmd /k "server.exe"
|
||||
rem Run the current source so local testing never uses an outdated server.exe.
|
||||
start "Warehouse MMS - Backend" /D "%~dp0" cmd /k "go run ./cmd/server"
|
||||
|
||||
echo Starting frontend (8083)...
|
||||
start "Warehouse MMS - Frontend" /D "%~dp0frontend" cmd /k "npm run dev"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user