完善出库和入库逻辑

This commit is contained in:
文睿 张 2026-09-20 11:18:00 +08:00
parent d9da2ed672
commit 332ac1ff7a
15 changed files with 266 additions and 46 deletions

View File

@ -419,8 +419,9 @@
```json ```json
{ {
"handler": "张三", "handler": "张三",
"supplier": "ABC 钢材", "supplier": "ABC 钢材",
"remark": "到货验收通过",
"items": [ "items": [
{ "material_id": 1, "quantity": 500, "warning_stock": 100, "warning_enabled": true }, { "material_id": 1, "quantity": 500, "warning_stock": 100, "warning_enabled": true },
{ "material_id": 2, "quantity": 200, "warning_stock": 0, "warning_enabled": false }, { "material_id": 2, "quantity": 200, "warning_stock": 0, "warning_enabled": false },
@ -433,6 +434,7 @@
|----------|--------|------|--------------------| |----------|--------|------|--------------------|
| handler | string | 是 | 经手人 | | handler | string | 是 | 经手人 |
| supplier | string | 是 | 供应商 | | supplier | string | 是 | 供应商 |
| remark | string | 否 | 备注 |
| items | array | 是 | 明细,至少 1 行 | | items | array | 是 | 明细,至少 1 行 |
**明细字段:** **明细字段:**
@ -448,7 +450,7 @@
> >
> 关闭预警而想保留原阈值时,把该物料当前的阈值连同 `warning_enabled: false` 一起传回即可(前端表单正是这么做的)。 > 关闭预警而想保留原阈值时,把该物料当前的阈值连同 `warning_enabled: false` 一起传回即可(前端表单正是这么做的)。
> >
> 本接口**不支持备注字段**。前端即便提交了 `remark`,服务端也会忽略。 `inbound_no` 由服务端按 `IN-` 加 8 位订单 ID 自动生成;`operator` 为创建该单据的当前登录用户。二者均由服务端维护,不能由请求体指定。
**响应:** `201 Created`,`data` 为创建后的入库单,结构同 `GET /inbound/:id`。 **响应:** `201 Created`,`data` 为创建后的入库单,结构同 `GET /inbound/:id`。
@ -569,8 +571,7 @@
{ {
"id": 1, "id": 1,
"outbound_date": "2026-01-01T08:00:00Z", "outbound_date": "2026-01-01T08:00:00Z",
"handler": "李四", "receiver": "装配车间",
"receiver": "装配车间",
"status": "completed", "status": "completed",
"created_at": "2026-01-01T08:00:00Z", "created_at": "2026-01-01T08:00:00Z",
"updated_at": "2026-01-01T08:00:00Z" "updated_at": "2026-01-01T08:00:00Z"
@ -642,8 +643,9 @@
```json ```json
{ {
"handler": "李四", "handler": "李四",
"receiver": "装配车间", "receiver": "装配车间",
"remark": "急用",
"items": [ "items": [
{ "material_id": 1, "quantity": 100 }, { "material_id": 1, "quantity": 100 },
{ "material_id": 2, "quantity": 50 } { "material_id": 2, "quantity": 50 }
@ -653,8 +655,8 @@
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|----------|--------|------|------------------| |----------|--------|------|------------------|
| handler | string | 是 | 经手人 |
| receiver | string | 是 | 领用人 | | receiver | string | 是 | 领用人 |
| remark | string | 否 | 备注 |
| items | array | 是 | 明细,至少 1 行 | | items | array | 是 | 明细,至少 1 行 |
**明细字段:** **明细字段:**
@ -664,6 +666,8 @@
| material_id | integer | 是 | 物料 ID | | material_id | integer | 是 | 物料 ID |
| quantity | integer | 是 | 出库数量,必须大于 0 | | quantity | integer | 是 | 出库数量,必须大于 0 |
`outbound_no` 由服务端按 `OUT-` 加 8 位订单 ID 自动生成;`operator` 与 `handler` 均由服务端设置为当前登录用户名,不能由请求体指定。
**响应:** `201 Created`,`data` 为创建后的出库单,结构同 `GET /outbound/:id`。 **响应:** `201 Created`,`data` 为创建后的出库单,结构同 `GET /outbound/:id`。
**状态码:** **状态码:**

View File

@ -39,9 +39,6 @@
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑出库单' : '新增出库单'" width="900px"> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑出库单' : '新增出库单'" width="900px">
<el-form :model="form" label-width="100px"> <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-form-item label="接收方">
<el-input v-model="form.receiver" placeholder="请输入接收方" /> <el-input v-model="form.receiver" placeholder="请输入接收方" />
</el-form-item> </el-form-item>
@ -131,7 +128,6 @@ const isEdit = ref(false)
const editId = ref(null) const editId = ref(null)
const form = reactive({ const form = reactive({
handler: '',
receiver: '', receiver: '',
remark: '', remark: '',
items: [{ material_id: null, materialSearchText: '', quantity: 1 }] items: [{ material_id: null, materialSearchText: '', quantity: 1 }]
@ -157,7 +153,7 @@ function handlePageChange(page) {
function showAddModal() { function showAddModal() {
isEdit.value = false isEdit.value = false
editId.value = null 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 dialogVisible.value = true
} }
@ -167,7 +163,6 @@ async function showEditModal(row) {
try { try {
const res = await outboundStore.fetchOutboundDetail(row.id) const res = await outboundStore.fetchOutboundDetail(row.id)
const order = res.data || res const order = res.data || res
form.handler = order.handler || ''
form.receiver = order.receiver || '' form.receiver = order.receiver || ''
form.remark = order.remark || '' form.remark = order.remark || ''
form.items = order.items && order.items.length > 0 form.items = order.items && order.items.length > 0
@ -245,7 +240,6 @@ async function handleSave() {
return return
} }
const data = { const data = {
handler: form.handler,
receiver: form.receiver, receiver: form.receiver,
remark: form.remark, remark: form.remark,
items: validItems items: validItems

View File

@ -458,7 +458,11 @@ func (h *Handler) CreateInbound(c *gin.Context) {
return 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 err != nil {
if errors.Is(err, service.ErrMaterialNotFound) { if errors.Is(err, service.ErrMaterialNotFound) {
response.BadRequest(c, "material not found") response.BadRequest(c, "material not found")
@ -589,7 +593,11 @@ func (h *Handler) CreateOutbound(c *gin.Context) {
return 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 err != nil {
if errors.Is(err, service.ErrMaterialNotFound) { if errors.Is(err, service.ErrMaterialNotFound) {
response.BadRequest(c, "material not found") response.BadRequest(c, "material not found")
@ -635,7 +643,11 @@ func (h *Handler) UpdateOutbound(c *gin.Context) {
return 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 err != nil {
if errors.Is(err, service.ErrMaterialNotFound) { if errors.Is(err, service.ErrMaterialNotFound) {
response.BadRequest(c, "material not found") response.BadRequest(c, "material not found")

View File

@ -1,6 +1,7 @@
package model package model
import ( import (
"fmt"
"time" "time"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
@ -55,9 +56,12 @@ type ApplyItem struct {
// InboundOrder represents an inbound order // InboundOrder represents an inbound order
type InboundOrder struct { type InboundOrder struct {
ID int64 `json:"id"` ID int64 `json:"id"`
InboundNo string `json:"inbound_no"`
Operator string `json:"operator"`
InboundDate time.Time `json:"inbound_date"` InboundDate time.Time `json:"inbound_date"`
Handler string `json:"handler"` Handler string `json:"handler"`
Supplier string `json:"supplier"` Supplier string `json:"supplier"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
@ -77,9 +81,12 @@ type InboundItem struct {
// OutboundOrder represents an outbound order // OutboundOrder represents an outbound order
type OutboundOrder struct { type OutboundOrder struct {
ID int64 `json:"id"` ID int64 `json:"id"`
OutboundNo string `json:"outbound_no"`
Operator string `json:"operator"`
OutboundDate time.Time `json:"outbound_date"` OutboundDate time.Time `json:"outbound_date"`
Handler string `json:"handler"` Handler string `json:"handler"`
Receiver string `json:"receiver"` Receiver string `json:"receiver"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
@ -96,6 +103,13 @@ type OutboundItem struct {
MaterialName string `json:"material_name,omitempty"` 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 // StockLog represents a stock change log
type StockLog struct { type StockLog struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@ -216,6 +230,7 @@ type UpdateMaterialRequest struct {
type CreateInboundRequest struct { type CreateInboundRequest struct {
Handler string `json:"handler" binding:"required"` Handler string `json:"handler" binding:"required"`
Supplier string `json:"supplier" binding:"required"` Supplier string `json:"supplier" binding:"required"`
Remark string `json:"remark"`
Items []struct { Items []struct {
MaterialID int64 `json:"material_id" binding:"required"` MaterialID int64 `json:"material_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"` Quantity int `json:"quantity" binding:"required,gt=0"`
@ -229,8 +244,11 @@ type CreateInboundRequest struct {
// CreateOutboundRequest represents the request to create an outbound order // CreateOutboundRequest represents the request to create an outbound order
type CreateOutboundRequest struct { 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"` Receiver string `json:"receiver" binding:"required"`
Remark string `json:"remark"`
Items []struct { Items []struct {
MaterialID int64 `json:"material_id" binding:"required"` MaterialID int64 `json:"material_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"` Quantity int `json:"quantity" binding:"required,gt=0"`
@ -241,6 +259,7 @@ type CreateOutboundRequest struct {
type UpdateInboundRequest struct { type UpdateInboundRequest struct {
Handler string `json:"handler"` Handler string `json:"handler"`
Supplier string `json:"supplier"` Supplier string `json:"supplier"`
Remark string `json:"remark"`
Items []struct { Items []struct {
MaterialID int64 `json:"material_id" binding:"required"` MaterialID int64 `json:"material_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"` Quantity int `json:"quantity" binding:"required,gt=0"`
@ -255,7 +274,9 @@ type UpdateInboundRequest struct {
// UpdateOutboundRequest represents the request to update an outbound order // UpdateOutboundRequest represents the request to update an outbound order
type UpdateOutboundRequest struct { type UpdateOutboundRequest struct {
Handler string `json:"handler"` Handler string `json:"handler"`
Operator string `json:"-"`
Receiver string `json:"receiver"` Receiver string `json:"receiver"`
Remark string `json:"remark"`
Items []struct { Items []struct {
MaterialID int64 `json:"material_id" binding:"required"` MaterialID int64 `json:"material_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"` Quantity int `json:"quantity" binding:"required,gt=0"`

View 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
}

View File

@ -195,8 +195,8 @@ func (r *ApplicationRepository) GetByIDWithDetails(id int64) (*model.MaterialApp
} }
func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder) error { func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder) error {
query := `INSERT INTO outbound_orders (outbound_date, handler, receiver, status) VALUES (NOW(), ?, ?, ?)` query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, status) VALUES (NOW(), ?, ?, ?, ?)`
result, err := database.DB.Exec(query, order.Handler, order.Receiver, order.Status) result, err := database.DB.Exec(query, order.Operator, order.Handler, order.Receiver, order.Status)
if err != nil { if err != nil {
return err return err
} }
@ -205,12 +205,16 @@ func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder)
return err return err
} }
order.ID = id 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 return nil
} }
func (r *ApplicationRepository) CreateOutboundOrderTx(tx *sql.Tx, order *model.OutboundOrder) error { func (r *ApplicationRepository) CreateOutboundOrderTx(tx *sql.Tx, order *model.OutboundOrder) error {
query := `INSERT INTO outbound_orders (outbound_date, handler, receiver, status) VALUES (NOW(), ?, ?, ?)` query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, status) VALUES (NOW(), ?, ?, ?, ?)`
result, err := tx.Exec(query, order.Handler, order.Receiver, order.Status) result, err := tx.Exec(query, order.Operator, order.Handler, order.Receiver, order.Status)
if err != nil { if err != nil {
return err return err
} }
@ -219,6 +223,10 @@ func (r *ApplicationRepository) CreateOutboundOrderTx(tx *sql.Tx, order *model.O
return err return err
} }
order.ID = id 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 return nil
} }

View File

@ -32,8 +32,8 @@ func (r *InboundRepo) CreateTx(tx *sql.Tx, order *model.InboundOrder) error {
defer tx.Rollback() defer tx.Rollback()
} }
query := `INSERT INTO inbound_orders (inbound_date, handler, supplier) VALUES (?, ?, ?)` query := `INSERT INTO inbound_orders (inbound_date, operator, handler, supplier, remark) VALUES (?, ?, ?, ?, ?)`
result, err := tx.Exec(query, order.InboundDate, order.Handler, order.Supplier) result, err := tx.Exec(query, order.InboundDate, order.Operator, order.Handler, order.Supplier, order.Remark)
if err != nil { if err != nil {
return err return err
} }
@ -42,6 +42,10 @@ func (r *InboundRepo) CreateTx(tx *sql.Tx, order *model.InboundOrder) error {
return err return err
} }
order.ID = orderID 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 { for i := range order.Items {
item := &order.Items[i] 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) { func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) {
order := &model.InboundOrder{} order := &model.InboundOrder{}
query := `SELECT id, inbound_date, handler, supplier, created_at, updated_at FROM inbound_orders WHERE id = ?` 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.InboundDate, &order.Handler, &order.Supplier, &order.CreatedAt, &order.UpdatedAt) 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 { if err != nil {
return nil, err return nil, err
} }
@ -111,7 +115,7 @@ func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrd
return nil, 0, err 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{} var args []interface{}
if search != "" { if search != "" {
@ -131,7 +135,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.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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -214,8 +218,8 @@ func (r *InboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateInboundReq
} }
// Update order basic fields // Update order basic fields
query := `UPDATE inbound_orders SET handler = ?, supplier = ? WHERE id = ?` query := `UPDATE inbound_orders SET handler = ?, supplier = ?, remark = ? WHERE id = ?`
_, err := tx.Exec(query, req.Handler, req.Supplier, id) _, err := tx.Exec(query, req.Handler, req.Supplier, req.Remark, id)
if err != nil { if err != nil {
return err return err
} }

View 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)
}
}

View File

@ -32,8 +32,8 @@ func (r *OutboundRepo) CreateTx(tx *sql.Tx, order *model.OutboundOrder) error {
defer tx.Rollback() defer tx.Rollback()
} }
query := `INSERT INTO outbound_orders (outbound_date, handler, receiver) VALUES (?, ?, ?)` query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, remark) VALUES (?, ?, ?, ?, ?)`
result, err := tx.Exec(query, order.OutboundDate, order.Handler, order.Receiver) result, err := tx.Exec(query, order.OutboundDate, order.Operator, order.Handler, order.Receiver, order.Remark)
if err != nil { if err != nil {
return err return err
} }
@ -42,6 +42,10 @@ func (r *OutboundRepo) CreateTx(tx *sql.Tx, order *model.OutboundOrder) error {
return err return err
} }
order.ID = orderID 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 { for i := range order.Items {
item := &order.Items[i] 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) { func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) {
order := &model.OutboundOrder{} order := &model.OutboundOrder{}
query := `SELECT id, outbound_date, handler, receiver, created_at, updated_at FROM outbound_orders WHERE id = ?` 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.OutboundDate, &order.Handler, &order.Receiver, &order.CreatedAt, &order.UpdatedAt) 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 { if err != nil {
return nil, err return nil, err
} }
@ -111,7 +115,7 @@ func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundO
return nil, 0, err 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{} var args []interface{}
if search != "" { if search != "" {
@ -131,7 +135,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.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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -214,8 +218,8 @@ func (r *OutboundRepo) UpdateTx(tx *sql.Tx, id int64, req *model.UpdateOutboundR
} }
// Update order basic fields // Update order basic fields
query := `UPDATE outbound_orders SET handler = ?, receiver = ? WHERE id = ?` query := `UPDATE outbound_orders SET operator = ?, handler = ?, receiver = ?, remark = ? WHERE id = ?`
_, err := tx.Exec(query, req.Handler, req.Receiver, id) _, err := tx.Exec(query, req.Operator, req.Handler, req.Receiver, req.Remark, id)
if err != nil { if err != nil {
return err return err
} }

View File

@ -127,6 +127,7 @@ func (s *ApplicationService) Approve(id int64, approverID int64, autoOutbound bo
// Create outbound order within transaction // Create outbound order within transaction
order := &model.OutboundOrder{ order := &model.OutboundOrder{
Handler: approverName, Handler: approverName,
Operator: approverName,
Receiver: app.Username, Receiver: app.Username,
Status: "completed", Status: "completed",
} }

View File

@ -31,11 +31,13 @@ func resolveWarningEnabled(enabled *bool) bool {
return *enabled 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{ order := &model.InboundOrder{
InboundDate: time.Now(), InboundDate: time.Now(),
Operator: operator,
Handler: req.Handler, Handler: req.Handler,
Supplier: req.Supplier, Supplier: req.Supplier,
Remark: req.Remark,
Items: make([]model.InboundItem, len(req.Items)), Items: make([]model.InboundItem, len(req.Items)),
} }

View File

@ -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{ order := &model.OutboundOrder{
OutboundDate: time.Now(), OutboundDate: time.Now(),
Handler: req.Handler, Operator: operator,
Handler: operator,
Receiver: req.Receiver, Receiver: req.Receiver,
Remark: req.Remark,
Items: make([]model.OutboundItem, len(req.Items)), Items: make([]model.OutboundItem, len(req.Items)),
} }
@ -184,7 +186,7 @@ func (s *OutboundService) Delete(id int64) error {
return nil 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 // Get old order data
oldOrder, err := s.outboundRepo.GetByID(id) oldOrder, err := s.outboundRepo.GetByID(id)
if err != nil { if err != nil {
@ -265,6 +267,9 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
} }
// Update order in repository // Update order in repository
// 客户端不能指定操作人;编辑也由当前登录用户接管操作人和兼容字段 handler。
req.Handler = operator
req.Operator = operator
err = s.outboundRepo.UpdateTx(tx, id, req) err = s.outboundRepo.UpdateTx(tx, id, req)
if err != nil { if err != nil {
return nil, err return nil, err

View 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;

View File

@ -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 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 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 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 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 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 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; 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;

View File

@ -4,7 +4,8 @@ title Warehouse Management System Launcher
cd /d "%~dp0" cd /d "%~dp0"
echo Starting backend (8082)... 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)... echo Starting frontend (8083)...
start "Warehouse MMS - Frontend" /D "%~dp0frontend" cmd /k "npm run dev" start "Warehouse MMS - Frontend" /D "%~dp0frontend" cmd /k "npm run dev"