feat: 按公司隔离物料申请与审批出库

This commit is contained in:
文睿 张 2026-10-09 12:37:08 +08:00
parent b0cab477db
commit a1bc0539dc
10 changed files with 875 additions and 261 deletions

View File

@ -31,10 +31,11 @@ func main() {
log.Fatalf("Failed to create default users: %v", err) log.Fatalf("Failed to create default users: %v", err)
} }
// Initialize applications table // 申请表的数据库结构统一由 scripts/init.sql(空库)或增量 SQL(升级)管理;
// 服务启动只校验运行所需的组织列是否已到位,缺列时报明确的升级错误,不再建表。
appRepo := repository.NewApplicationRepository() appRepo := repository.NewApplicationRepository()
if err := appRepo.InitApplicationTable(); err != nil { if err := appRepo.CheckApplicationSchema(); err != nil {
log.Fatalf("Failed to initialize applications table: %v", err) log.Fatalf("Failed to verify applications schema: %v", err)
} }
r := gin.Default() r := gin.Default()

View File

@ -1417,7 +1417,12 @@ func (h *Handler) ListMyApplications(c *gin.Context) {
} }
} }
apps, total, err := h.appSvc.ListByUser(userID, status, limit, offset) orgID, ok := organizationID(c)
if !ok {
return
}
apps, total, err := h.appSvc.ListByUser(orgID, userID, status, limit, offset)
if err != nil { if err != nil {
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
@ -1431,6 +1436,11 @@ func (h *Handler) ListApplications(c *gin.Context) {
return return
} }
orgID, ok := organizationID(c)
if !ok {
return
}
status := c.Query("status") status := c.Query("status")
limit := 20 limit := 20
@ -1449,7 +1459,7 @@ func (h *Handler) ListApplications(c *gin.Context) {
} }
} }
apps, total, err := h.appSvc.ListAll(status, limit, offset) apps, total, err := h.appSvc.ListAll(orgID, status, limit, offset)
if err != nil { if err != nil {
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
@ -1464,9 +1474,18 @@ func (h *Handler) CancelApplication(c *gin.Context) {
return return
} }
orgID, ok := organizationID(c)
if !ok {
return
}
userID := middleware.GetCurrentUserID(c) userID := middleware.GetCurrentUserID(c)
if err := h.appSvc.Cancel(id, userID); err != nil { if err := h.appSvc.Cancel(orgID, id, userID); err != nil {
if err.Error() == "cannot cancel others' applications" || err.Error() == "cannot cancel application" { if errors.Is(err, service.ErrApplicationNotFound) {
response.NotFound(c, "application not found")
return
}
if errors.Is(err, service.ErrCannotCancelOthers) || errors.Is(err, service.ErrCannotCancelApplication) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -1534,18 +1553,23 @@ func (h *Handler) RejectApplication(c *gin.Context) {
return return
} }
orgID, ok := organizationID(c)
if !ok {
return
}
var req model.RejectApplicationRequest var req model.RejectApplicationRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "rejection reason is required") response.BadRequest(c, "rejection reason is required")
return return
} }
if err := h.appSvc.Reject(id, req.Reason); err != nil { if err := h.appSvc.Reject(orgID, id, req.Reason); err != nil {
if err == service.ErrApplicationNotFound { if errors.Is(err, service.ErrApplicationNotFound) {
response.NotFound(c, "application not found") response.NotFound(c, "application not found")
return return
} }
if err == service.ErrApplicationNotPending { if errors.Is(err, service.ErrApplicationNotPending) {
response.BadRequest(c, "application is not pending") response.BadRequest(c, "application is not pending")
return return
} }

View File

@ -0,0 +1,123 @@
package handler
import (
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
)
// 本文件验收 Task 5C 的申请/审批接口隔离:列表、详情、取消、驳回、审批都带请求头公司,
// 在 A 上下文操作 B 的申请读取阶段就命中空行,回 404,且不落任何状态改动。
// 中间件链:AuthMiddleware → OrganizationMiddleware → Handler,所有 SQL 期望都必须带对组织参数。
const (
appListCountPattern = `SELECT COUNT\(\*\) FROM material_applications a WHERE a.organization_id = \? AND a.user_id = \?`
appListPattern = `SELECT a.id, a.organization_id, a.user_id`
appUpdatePattern = `UPDATE material_applications SET status`
)
func applicationHeadRows(t *testing.T, organizationID, id, userID int64, status string) *sqlmock.Rows {
t.Helper()
now := time.Now()
return sqlmock.NewRows([]string{"id", "organization_id", "user_id", "purpose", "status", "rejection_reason", "approved_by", "created_at", "updated_at", "username"}).
AddRow(id, organizationID, userID, "领用", status, "", 0, now, now, "shared")
}
func emptyApplicationHeadRows() *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "organization_id", "user_id", "purpose", "status", "rejection_reason", "approved_by", "created_at", "updated_at", "username"})
}
func emptyApplicationItemRows() *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "application_id", "material_id", "quantity", "material_code", "name"})
}
// TestApplicationListScopesToOrganization:我的申请列表按请求公司 + 当前用户查询,
// 计数与数据查询都带组织参数。
func TestApplicationListScopesToOrganization(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectQuery(appListCountPattern).WithArgs(int64(1), int64(7)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery(appListPattern).WithArgs(int64(1), int64(7), 20, 0).WillReturnRows(emptyApplicationHeadRows())
w := newStockRequest(t, "GET", "/api/applications/my", "", 1)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestApplicationCancelScopesToOrganization:本人取消待审申请时,条件更新带请求公司、
// 申请 ID 和原状态 pending。
func TestApplicationCancelScopesToOrganization(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectQuery(appListPattern).WithArgs(int64(9), int64(1)).WillReturnRows(applicationHeadRows(t, 1, 9, 7, model.ApplicationStatusPending))
mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1), int64(9)).WillReturnRows(emptyApplicationItemRows())
mock.ExpectExec(appUpdatePattern).WithArgs(model.ApplicationStatusCancelled, int64(1), int64(9), model.ApplicationStatusPending).WillReturnResult(sqlmock.NewResult(0, 1))
w := newStockRequest(t, "POST", "/api/applications/9/cancel", "", 1)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestApplicationCancelCrossOrganizationReturnsNotFound:A 上下文取消 B 的申请,读取阶段
// 命中空行,回 404,不执行任何条件更新。
func TestApplicationCancelCrossOrganizationReturnsNotFound(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectQuery(appListPattern).WithArgs(int64(9), int64(1)).WillReturnRows(emptyApplicationHeadRows())
w := newStockRequest(t, "POST", "/api/applications/9/cancel", "", 1)
if w.Code != 404 {
t.Fatalf("status=%d body=%s, want 404", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestApplicationRejectCrossOrganizationReturnsNotFound:审批人驳回别家公司的申请回 404。
func TestApplicationRejectCrossOrganizationReturnsNotFound(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "super_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectQuery(appListPattern).WithArgs(int64(9), int64(1)).WillReturnRows(emptyApplicationHeadRows())
w := newStockRequest(t, "POST", "/api/applications/9/reject", `{"reason":"no"}`, 1)
if w.Code != 404 {
t.Fatalf("status=%d body=%s, want 404", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestApplicationApproveCrossOrganizationReturnsNotFound:自动出库审批在事务内锁定别家
// 公司的申请命中空行,回 404,整体回滚,不解析本公司 scope。
func TestApplicationApproveCrossOrganizationReturnsNotFound(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "super_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectBegin()
mock.ExpectQuery(appListPattern).WithArgs(int64(9), int64(1)).WillReturnRows(emptyApplicationHeadRows())
mock.ExpectRollback()
w := newStockRequest(t, "POST", "/api/applications/9/approve", `{"auto_outbound":true}`, 1)
if w.Code != 404 {
t.Fatalf("status=%d body=%s, want 404", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -361,7 +361,9 @@ const (
type MaterialApplication struct { type MaterialApplication struct {
ID int64 `json:"id"` ID int64 `json:"id"`
// ApplicationNo 不落库,由主键派生(同入库单/出库单的单号规则),查询时统一补齐。 // ApplicationNo 不落库,由主键派生(同入库单/出库单的单号规则),查询时统一补齐。
ApplicationNo string `json:"application_no,omitempty"` ApplicationNo string `json:"application_no,omitempty"`
// OrganizationID 是申请所属公司,落库并由服务端取可信上下文填写,忽略请求体伪造的归属。
OrganizationID int64 `json:"organization_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Purpose string `json:"purpose,omitempty"` Purpose string `json:"purpose,omitempty"`

View File

@ -2,11 +2,18 @@ package repository
import ( import (
"database/sql" "database/sql"
"errors"
"fmt"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database" "github.com/wmms/wmms/pkg/database"
) )
// ErrApplicationStatusConflict 表示带原状态条件的 UPDATE 未命中任何行:申请不属于当前
// 公司,或状态已被并发请求改变(不再是 pending)。调用方据此回 404 或"状态已变更",
// 而不是把并发覆盖当成成功。
var ErrApplicationStatusConflict = errors.New("application status conflict")
type ApplicationRepository struct{} type ApplicationRepository struct{}
func NewApplicationRepository() *ApplicationRepository { func NewApplicationRepository() *ApplicationRepository {
@ -20,8 +27,9 @@ func (r *ApplicationRepository) Create(app *model.MaterialApplication) error {
} }
defer tx.Rollback() defer tx.Rollback()
query := `INSERT INTO material_applications (user_id, purpose, status) VALUES (?, ?, ?)` // organization_id 由服务端从可信上下文写入,不接受请求体伪造的归属。
result, err := tx.Exec(query, app.UserID, app.Purpose, app.Status) query := `INSERT INTO material_applications (organization_id, user_id, purpose, status) VALUES (?, ?, ?, ?)`
result, err := tx.Exec(query, app.OrganizationID, app.UserID, app.Purpose, app.Status)
if err != nil { if err != nil {
return err return err
} }
@ -45,16 +53,21 @@ func (r *ApplicationRepository) Create(app *model.MaterialApplication) error {
return tx.Commit() return tx.Commit()
} }
func (r *ApplicationRepository) GetByID(id int64) (*model.MaterialApplication, error) { // getByID 按公司读取申请头表加明细;lock=true 时用 FOR UPDATE 锁定当前公司的原申请,
query := `SELECT a.id, a.user_id, a.purpose, a.status, a.rejection_reason, a.approved_by, a.created_at, a.updated_at, // 供审批事务在同一把锁下判定状态并落库存。别家公司的申请在这里命中 sql.ErrNoRows。
func (r *ApplicationRepository) getByID(q orderQuerier, organizationID, id int64, lock bool) (*model.MaterialApplication, error) {
query := `SELECT a.id, a.organization_id, a.user_id, a.purpose, a.status, a.rejection_reason, a.approved_by, a.created_at, a.updated_at,
u.username u.username
FROM material_applications a FROM material_applications a
JOIN users u ON a.user_id = u.id JOIN users u ON a.user_id = u.id
WHERE a.id = ?` WHERE a.id = ? AND a.organization_id = ?`
if lock {
query += ` FOR UPDATE`
}
var app model.MaterialApplication var app model.MaterialApplication
err := database.DB.QueryRow(query, id).Scan( err := q.QueryRow(query, id, organizationID).Scan(
&app.ID, &app.UserID, &app.Purpose, &app.Status, &app.ID, &app.OrganizationID, &app.UserID, &app.Purpose, &app.Status,
&app.RejectionReason, &app.ApprovedBy, &app.CreatedAt, &app.UpdatedAt, &app.RejectionReason, &app.ApprovedBy, &app.CreatedAt, &app.UpdatedAt,
&app.Username, &app.Username,
) )
@ -62,8 +75,7 @@ func (r *ApplicationRepository) GetByID(id int64) (*model.MaterialApplication, e
return nil, err return nil, err
} }
// Fetch items app.Items, err = r.getApplicationItems(q, organizationID, app.ID)
app.Items, err = r.GetApplicationItems(id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -72,13 +84,16 @@ func (r *ApplicationRepository) GetByID(id int64) (*model.MaterialApplication, e
return &app, nil return &app, nil
} }
func (r *ApplicationRepository) GetApplicationItems(applicationID int64) ([]model.ApplicationItem, error) { // getApplicationItems 通过组织化申请头表关联物料:明细物料必须与申请同属一家公司,
// 否则会被关联条件挡掉(明细表本身没有组织列)。
func (r *ApplicationRepository) getApplicationItems(q orderQuerier, organizationID, applicationID int64) ([]model.ApplicationItem, error) {
query := `SELECT ai.id, ai.application_id, ai.material_id, ai.quantity, m.material_code, m.name query := `SELECT ai.id, ai.application_id, ai.material_id, ai.quantity, m.material_code, m.name
FROM application_items ai FROM application_items ai
JOIN materials m ON ai.material_id = m.id JOIN material_applications a ON a.id = ai.application_id AND a.organization_id = ?
JOIN materials m ON m.id = ai.material_id AND m.organization_id = a.organization_id
WHERE ai.application_id = ?` WHERE ai.application_id = ?`
rows, err := database.DB.Query(query, applicationID) rows, err := q.Query(query, organizationID, applicationID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -93,13 +108,32 @@ func (r *ApplicationRepository) GetApplicationItems(applicationID int64) ([]mode
} }
items = append(items, item) items = append(items, item)
} }
return items, nil return items, rows.Err()
} }
func (r *ApplicationRepository) ListAll(status string, userID int64, limit, offset int) ([]model.MaterialApplication, int, error) { func (r *ApplicationRepository) GetByID(organizationID, id int64) (*model.MaterialApplication, error) {
return r.getByID(database.DB, organizationID, id, false)
}
// GetByIDForUpdateTx 在事务内锁定当前公司的申请,别的公司的申请命中 sql.ErrNoRows。
func (r *ApplicationRepository) GetByIDForUpdateTx(tx *sql.Tx, organizationID, id int64) (*model.MaterialApplication, error) {
return r.getByID(tx, organizationID, id, true)
}
// GetApplicationItems 按公司读取申请明细;明细物料必须同属该公司。
func (r *ApplicationRepository) GetApplicationItems(organizationID, applicationID int64) ([]model.ApplicationItem, error) {
return r.getApplicationItems(database.DB, organizationID, applicationID)
}
func (r *ApplicationRepository) GetByIDWithDetails(organizationID, id int64) (*model.MaterialApplication, error) {
return r.GetByID(organizationID, id)
}
// ListAll 的计数与数据查询都带组织条件,可按状态、按用户(我的申请)进一步过滤。
func (r *ApplicationRepository) ListAll(organizationID int64, status string, userID int64, limit, offset int) ([]model.MaterialApplication, int, error) {
// Count query // Count query
countQuery := `SELECT COUNT(*) FROM material_applications a WHERE 1=1` countQuery := `SELECT COUNT(*) FROM material_applications a WHERE a.organization_id = ?`
var countArgs []interface{} countArgs := []interface{}{organizationID}
if status != "" { if status != "" {
countQuery += " AND a.status = ?" countQuery += " AND a.status = ?"
countArgs = append(countArgs, status) countArgs = append(countArgs, status)
@ -116,13 +150,13 @@ func (r *ApplicationRepository) ListAll(status string, userID int64, limit, offs
} }
// Data query // Data query
query := `SELECT a.id, a.user_id, a.purpose, a.status, a.rejection_reason, a.approved_by, a.created_at, a.updated_at, query := `SELECT a.id, a.organization_id, a.user_id, a.purpose, a.status, a.rejection_reason, a.approved_by, a.created_at, a.updated_at,
u.username u.username
FROM material_applications a FROM material_applications a
JOIN users u ON a.user_id = u.id JOIN users u ON a.user_id = u.id
WHERE 1=1` WHERE a.organization_id = ?`
var args []interface{} args := []interface{}{organizationID}
if status != "" { if status != "" {
query += " AND a.status = ?" query += " AND a.status = ?"
args = append(args, status) args = append(args, status)
@ -144,7 +178,7 @@ func (r *ApplicationRepository) ListAll(status string, userID int64, limit, offs
for rows.Next() { for rows.Next() {
var app model.MaterialApplication var app model.MaterialApplication
err := rows.Scan( err := rows.Scan(
&app.ID, &app.UserID, &app.Purpose, &app.Status, &app.ID, &app.OrganizationID, &app.UserID, &app.Purpose, &app.Status,
&app.RejectionReason, &app.ApprovedBy, &app.CreatedAt, &app.UpdatedAt, &app.RejectionReason, &app.ApprovedBy, &app.CreatedAt, &app.UpdatedAt,
&app.Username, &app.Username,
) )
@ -152,142 +186,71 @@ func (r *ApplicationRepository) ListAll(status string, userID int64, limit, offs
return nil, 0, err return nil, 0, err
} }
// Fetch items for each application // Fetch items for each application
app.Items, err = r.GetApplicationItems(app.ID) app.Items, err = r.getApplicationItems(database.DB, organizationID, app.ID)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
app.ApplicationNo = model.OrderNo("AP", app.ID) app.ApplicationNo = model.OrderNo("AP", app.ID)
apps = append(apps, app) apps = append(apps, app)
} }
return apps, total, nil return apps, total, rows.Err()
} }
func (r *ApplicationRepository) ListByUserID(userID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) { func (r *ApplicationRepository) ListByUserID(organizationID, userID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
return r.ListAll(status, userID, limit, offset) return r.ListAll(organizationID, status, userID, limit, offset)
} }
func (r *ApplicationRepository) ListAllApplications(status string, limit, offset int) ([]model.MaterialApplication, int, error) { func (r *ApplicationRepository) ListAllApplications(organizationID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
return r.ListAll(status, 0, limit, offset) return r.ListAll(organizationID, status, 0, limit, offset)
} }
func (r *ApplicationRepository) UpdateStatus(id int64, status string) error { // applicationUpdateResult 把条件 UPDATE 的"0 影响行"翻译成 ErrApplicationStatusConflict:
query := `UPDATE material_applications SET status = ?, updated_at = NOW() WHERE id = ?` // 公司、ID 或原状态任一不匹配都算冲突,调用方回 404 或"状态已变更"。
_, err := database.DB.Exec(query, status, id) func applicationUpdateResult(result sql.Result, err error) error {
return err
}
func (r *ApplicationRepository) Approve(id int64, approverID int64) error {
query := `UPDATE material_applications SET status = ?, approved_by = ?, updated_at = NOW() WHERE id = ?`
_, err := database.DB.Exec(query, model.ApplicationStatusApproved, approverID, id)
return err
}
func (r *ApplicationRepository) Reject(id int64, reason string) error {
query := `UPDATE material_applications SET status = ?, rejection_reason = ?, updated_at = NOW() WHERE id = ?`
_, err := database.DB.Exec(query, model.ApplicationStatusRejected, reason, id)
return err
}
func (r *ApplicationRepository) Cancel(id int64) error {
return r.UpdateStatus(id, model.ApplicationStatusCancelled)
}
func (r *ApplicationRepository) GetByIDWithDetails(id int64) (*model.MaterialApplication, error) {
return r.GetByID(id)
}
// outbound_orders 没有 status 列:出库单一经创建即为完成态,
// 这里与手工出库路径(OutboundRepo.CreateTx)写入同样的列。
func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder) error {
query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver) VALUES (NOW(), ?, ?, ?)`
result, err := database.DB.Exec(query, order.Operator, order.Handler, order.Receiver)
if err != nil { if err != nil {
return err return err
} }
id, err := result.LastInsertId() affected, err := result.RowsAffected()
if err != nil { if err != nil {
return err return err
} }
order.ID = id if affected == 0 {
order.OutboundNo = model.OrderNo("OUT", id) return ErrApplicationStatusConflict
if _, err := database.DB.Exec(`UPDATE outbound_orders SET outbound_no = ? WHERE id = ?`, order.OutboundNo, id); err != nil {
return err
} }
return nil return nil
} }
// outbound_orders 与 inbound_orders 一样没有 status 列,单据状态由查询层统一写死。 // UpdateStatus 带公司、ID 和原状态 pending 三个条件,防止并发覆盖。
// 这里写入的列必须和手工出库(OutboundRepo.CreateTx)一致,多写一列会让 INSERT 直接失败。 func (r *ApplicationRepository) UpdateStatus(organizationID, id int64, status string) error {
func (r *ApplicationRepository) CreateOutboundOrderTx(tx *sql.Tx, order *model.OutboundOrder) error { query := `UPDATE material_applications SET status = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status = ?`
query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver) VALUES (NOW(), ?, ?, ?)` result, err := database.DB.Exec(query, status, organizationID, id, model.ApplicationStatusPending)
result, err := tx.Exec(query, order.Operator, order.Handler, order.Receiver) return applicationUpdateResult(result, err)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
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
} }
func (r *ApplicationRepository) CreateOutboundItem(item *model.OutboundItem) error { // Approve 带公司、ID 和原状态 pending 条件,重复审批只会成功一次。
query := `INSERT INTO outbound_items (outbound_order_id, material_id, quantity) VALUES (?, ?, ?)` func (r *ApplicationRepository) Approve(organizationID, id, approverID int64) error {
result, err := database.DB.Exec(query, item.OutboundOrderID, item.MaterialID, item.Quantity) query := `UPDATE material_applications SET status = ?, approved_by = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status = ?`
if err != nil { result, err := database.DB.Exec(query, model.ApplicationStatusApproved, approverID, organizationID, id, model.ApplicationStatusPending)
return err return applicationUpdateResult(result, err)
}
id, err := result.LastInsertId()
if err != nil {
return err
}
item.ID = id
return nil
} }
func (r *ApplicationRepository) CreateOutboundItemTx(tx *sql.Tx, item *model.OutboundItem) error { // Reject 带公司、ID 和原状态 pending 条件,防并发覆盖。
query := `INSERT INTO outbound_items (outbound_order_id, material_id, quantity) VALUES (?, ?, ?)` func (r *ApplicationRepository) Reject(organizationID, id int64, reason string) error {
result, err := tx.Exec(query, item.OutboundOrderID, item.MaterialID, item.Quantity) query := `UPDATE material_applications SET status = ?, rejection_reason = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status = ?`
if err != nil { result, err := database.DB.Exec(query, model.ApplicationStatusRejected, reason, organizationID, id, model.ApplicationStatusPending)
return err return applicationUpdateResult(result, err)
}
id, err := result.LastInsertId()
if err != nil {
return err
}
item.ID = id
return nil
} }
func (r *ApplicationRepository) CreateStockLog(log *model.StockLog) error { func (r *ApplicationRepository) Cancel(organizationID, id int64) error {
query := `INSERT INTO stock_logs (material_id, type, quantity, related_order_type, related_order_id) VALUES (?, ?, ?, ?, ?)` return r.UpdateStatus(organizationID, id, model.ApplicationStatusCancelled)
result, err := database.DB.Exec(query, log.MaterialID, log.Type, log.Quantity, log.RelatedOrderType, log.RelatedOrderID)
if err != nil {
return err
}
_, err = result.LastInsertId()
return err
} }
func (r *ApplicationRepository) CreateStockLogTx(tx *sql.Tx, log *model.StockLog) error { // ApproveTx 在审批事务内条件更新状态,只允许从 pending 转 approved;0 影响行表示状态已被
query := `INSERT INTO stock_logs (material_id, type, quantity, related_order_type, related_order_id) VALUES (?, ?, ?, ?, ?)` // 并发请求改走,事务必须回滚,不产生重复出库。
result, err := tx.Exec(query, log.MaterialID, log.Type, log.Quantity, log.RelatedOrderType, log.RelatedOrderID) func (r *ApplicationRepository) ApproveTx(tx *sql.Tx, organizationID, id, approverID int64) error {
if err != nil { query := `UPDATE material_applications SET status = ?, approved_by = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status = ?`
return err result, err := tx.Exec(query, model.ApplicationStatusApproved, approverID, organizationID, id, model.ApplicationStatusPending)
} return applicationUpdateResult(result, err)
_, err = result.LastInsertId()
return err
}
func (r *ApplicationRepository) ApproveTx(tx *sql.Tx, id int64, approverID int64) error {
query := `UPDATE material_applications SET status = ?, approved_by = ?, updated_at = NOW() WHERE id = ?`
_, err := tx.Exec(query, model.ApplicationStatusApproved, approverID, id)
return err
} }
func (r *ApplicationRepository) GetApproverName(userID int64) (string, error) { func (r *ApplicationRepository) GetApproverName(userID int64) (string, error) {
@ -297,33 +260,24 @@ func (r *ApplicationRepository) GetApproverName(userID int64) (string, error) {
return username, err return username, err
} }
func (r *ApplicationRepository) InitApplicationTable() error { // CheckApplicationSchema 在服务启动时只校验运行所需的列是否已随初始化/增量 SQL 到位,
query := ` // 不再建表。缺列时报明确的升级错误,避免旧定义在新环境创建一张缺少组织列的申请表。
CREATE TABLE IF NOT EXISTS material_applications ( func (r *ApplicationRepository) CheckApplicationSchema() error {
id BIGINT AUTO_INCREMENT PRIMARY KEY, required := []struct{ table, column string }{
user_id BIGINT NOT NULL, {"material_applications", "organization_id"},
purpose VARCHAR(500) DEFAULT '',
status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending',
rejection_reason VARCHAR(500) DEFAULT '',
approved_by BIGINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)`
_, err := database.DB.Exec(query)
if err != nil {
return err
} }
for _, c := range required {
// Create application_items table var count int
itemsQuery := ` err := database.DB.QueryRow(
CREATE TABLE IF NOT EXISTS application_items ( `SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
id BIGINT AUTO_INCREMENT PRIMARY KEY, c.table, c.column,
application_id BIGINT NOT NULL, ).Scan(&count)
material_id BIGINT NOT NULL, if err != nil {
quantity INT NOT NULL, return err
FOREIGN KEY (application_id) REFERENCES material_applications(id) ON DELETE CASCADE, }
FOREIGN KEY (material_id) REFERENCES materials(id) if count == 0 {
)` return fmt.Errorf("数据库结构缺少 %s.%s:请先执行 scripts/init.sql(空库)或 scripts/upgrades/2026-10-08-company-master-data-isolation.sql(升级)完成建表/升级", c.table, c.column)
_, err = database.DB.Exec(itemsQuery) }
return err }
return nil
} }

View File

@ -0,0 +1,184 @@
package repository
import (
"database/sql"
"testing"
"github.com/wmms/wmms/internal/model"
)
// seedApplicationIsolation 复用两公司物料/仓库基础数据,并补一个跨两公司都授权、可用同一
// 用户在 A/B 各自建申请的用户。company 101=公司A,102=公司B。
func seedApplicationIsolation(t *testing.T, db *sql.DB) {
t.Helper()
seedOrderIsolation(t, db)
mustSQL(t, db, `INSERT INTO users(id,username,password) VALUES (7,'shared','x')`)
}
func newIsolationApplication(organizationID, materialID int64) *model.MaterialApplication {
return &model.MaterialApplication{
OrganizationID: organizationID,
UserID: 7,
Purpose: "领用",
Status: model.ApplicationStatusPending,
Items: []model.ApplicationItem{{MaterialID: materialID, Quantity: 2}},
}
}
// 同一用户在 A/B 各自创建的申请在对应公司可见,跨公司读不到。
func TestApplicationRepoOrganizationIsolationReads(t *testing.T) {
db := isolationDB(t, false)
seedApplicationIsolation(t, db)
repo := NewApplicationRepository()
appA := newIsolationApplication(101, 101)
appB := newIsolationApplication(102, 102)
if err := repo.Create(appA); err != nil {
t.Fatal(err)
}
if err := repo.Create(appB); err != nil {
t.Fatal(err)
}
own, err := repo.GetByID(101, appA.ID)
if err != nil || own.OrganizationID != 101 || len(own.Items) != 1 || own.Items[0].MaterialID != 101 {
t.Fatalf("A 读自己的申请失败: %+v err=%v", own, err)
}
if _, err := repo.GetByID(101, appB.ID); err != sql.ErrNoRows {
t.Fatalf("A 读 B 的申请必须 ErrNoRows, got %v", err)
}
if _, err := repo.GetByID(102, appA.ID); err != sql.ErrNoRows {
t.Fatalf("B 读 A 的申请必须 ErrNoRows, got %v", err)
}
appsA, totalA, err := repo.ListAllApplications(101, "", 20, 0)
if err != nil || totalA != 1 || len(appsA) != 1 || appsA[0].ID != appA.ID {
t.Fatalf("A 审批列表=%+v total=%d err=%v", appsA, totalA, err)
}
appsB, totalB, err := repo.ListAllApplications(102, "", 20, 0)
if err != nil || totalB != 1 || len(appsB) != 1 || appsB[0].ID != appB.ID {
t.Fatalf("B 审批列表=%+v total=%d err=%v", appsB, totalB, err)
}
mineA, mineTotalA, err := repo.ListByUserID(101, 7, "", 20, 0)
if err != nil || mineTotalA != 1 || mineA[0].ID != appA.ID {
t.Fatalf("A 我的申请=%+v total=%d err=%v", mineA, mineTotalA, err)
}
mineB, mineTotalB, err := repo.ListByUserID(102, 7, "", 20, 0)
if err != nil || mineTotalB != 1 || mineB[0].ID != appB.ID {
t.Fatalf("B 我的申请=%+v total=%d err=%v", mineB, mineTotalB, err)
}
}
// 明细经组织化申请头表关联物料:头表属于 A,混入的 B 公司物料会被关联条件挡掉。
func TestApplicationRepoCrossCompanyItemHidden(t *testing.T) {
db := isolationDB(t, false)
seedApplicationIsolation(t, db)
repo := NewApplicationRepository()
appA := newIsolationApplication(101, 101)
if err := repo.Create(appA); err != nil {
t.Fatal(err)
}
// 物料外键合法(102 存在),但属于公司 B,读取时的同组织关联会把它挡掉。
mustSQL(t, db, `INSERT INTO application_items(application_id, material_id, quantity) VALUES (?, ?, ?)`, appA.ID, 102, 9)
loaded, err := repo.GetByID(101, appA.ID)
if err != nil {
t.Fatal(err)
}
if len(loaded.Items) != 1 || loaded.Items[0].MaterialID != 101 {
t.Fatalf("跨公司物料明细必须被关联条件挡掉, got %+v", loaded.Items)
}
}
// 状态更新带公司、ID 和原状态条件:别家公司改不动;本公司重复审批/取消非待审也会被条件挡下。
func TestApplicationRepoStatusUpdatesScopedAndConditional(t *testing.T) {
db := isolationDB(t, false)
seedApplicationIsolation(t, db)
repo := NewApplicationRepository()
appA := newIsolationApplication(101, 101)
if err := repo.Create(appA); err != nil {
t.Fatal(err)
}
// 别家公司操作 A 的申请:公司条件不匹配,一律冲突。
if err := repo.Approve(102, appA.ID, 5); err != ErrApplicationStatusConflict {
t.Fatalf("B 审批 A 的申请必须冲突, got %v", err)
}
if err := repo.Reject(102, appA.ID, "x"); err != ErrApplicationStatusConflict {
t.Fatalf("B 驳回 A 的申请必须冲突, got %v", err)
}
if err := repo.Cancel(102, appA.ID); err != ErrApplicationStatusConflict {
t.Fatalf("B 取消 A 的申请必须冲突, got %v", err)
}
if status := applicationStatus(t, db, appA.ID); status != model.ApplicationStatusPending {
t.Fatalf("跨公司操作改变了状态: %s", status)
}
// 本公司首次审批成功;重复审批因原状态已变(非 pending)被条件更新挡下。
if err := repo.Approve(101, appA.ID, 5); err != nil {
t.Fatal(err)
}
if status := applicationStatus(t, db, appA.ID); status != model.ApplicationStatusApproved {
t.Fatalf("审批后状态=%s, want approved", status)
}
if err := repo.Approve(101, appA.ID, 5); err != ErrApplicationStatusConflict {
t.Fatalf("重复审批必须被条件挡下, got %v", err)
}
if err := repo.Cancel(101, appA.ID); err != ErrApplicationStatusConflict {
t.Fatalf("取消非待审申请必须冲突, got %v", err)
}
}
// 审批事务锁定当前公司的申请:锁别家公司的申请命中空行。
func TestApplicationRepoLockForUpdateScopesToOrganization(t *testing.T) {
db := isolationDB(t, false)
seedApplicationIsolation(t, db)
repo := NewApplicationRepository()
appA := newIsolationApplication(101, 101)
if err := repo.Create(appA); err != nil {
t.Fatal(err)
}
tx, err := BeginTx()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if _, err := repo.GetByIDForUpdateTx(tx, 102, appA.ID); err != sql.ErrNoRows {
t.Fatalf("锁别家公司申请必须 ErrNoRows, got %v", err)
}
locked, err := repo.GetByIDForUpdateTx(tx, 101, appA.ID)
if err != nil || locked.ID != appA.ID {
t.Fatalf("锁本公司申请失败: %+v err=%v", locked, err)
}
}
func applicationStatus(t *testing.T, db *sql.DB, id int64) string {
t.Helper()
var status string
if err := db.QueryRow(`SELECT status FROM material_applications WHERE id = ?`, id).Scan(&status); err != nil {
t.Fatal(err)
}
return status
}
// 服务启动只检查所需列:新库通过;缺少 organization_id 的旧库必须报明确的升级错误,
// 而不是在新环境按旧定义建一张缺列的申请表。
func TestCheckApplicationSchema(t *testing.T) {
t.Run("fresh schema passes", func(t *testing.T) {
isolationDB(t, false)
if err := NewApplicationRepository().CheckApplicationSchema(); err != nil {
t.Fatalf("新库不应报缺列: %v", err)
}
})
t.Run("legacy schema is rejected", func(t *testing.T) {
isolationDB(t, true)
if err := NewApplicationRepository().CheckApplicationSchema(); err == nil {
t.Fatal("缺少 organization_id 的旧库必须报升级错误")
}
})
}

View File

@ -1,7 +1,9 @@
package service package service
import ( import (
"database/sql"
"errors" "errors"
"time"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
@ -12,24 +14,27 @@ var (
ErrApplicationNotFound = errors.New("application not found") ErrApplicationNotFound = errors.New("application not found")
ErrCannotCancelApplication = errors.New("cannot cancel application") ErrCannotCancelApplication = errors.New("cannot cancel application")
ErrApplicationNotPending = errors.New("application is not pending") ErrApplicationNotPending = errors.New("application is not pending")
ErrCannotCancelOthers = errors.New("cannot cancel others' applications")
) )
type ApplicationService struct { type ApplicationService struct {
appRepo *repository.ApplicationRepository appRepo *repository.ApplicationRepository
materialSvc *MaterialService outboundRepo *repository.OutboundRepo
stockRepo *repository.StockRepo materialSvc *MaterialService
stockRepo *repository.StockRepo
} }
func NewApplicationService() *ApplicationService { func NewApplicationService() *ApplicationService {
return &ApplicationService{ return &ApplicationService{
appRepo: repository.NewApplicationRepository(), appRepo: repository.NewApplicationRepository(),
materialSvc: NewMaterialService(), outboundRepo: repository.NewOutboundRepo(),
stockRepo: repository.NewStockRepo(), materialSvc: NewMaterialService(),
stockRepo: repository.NewStockRepo(),
} }
} }
func (s *ApplicationService) Create(organizationID, userID int64, req *model.CreateApplicationRequest) (*model.MaterialApplication, error) { func (s *ApplicationService) Create(organizationID, userID int64, req *model.CreateApplicationRequest) (*model.MaterialApplication, error) {
// Build items from request // Build items from request,逐个校验物料属于当前公司且未作废。
var items []model.ApplicationItem var items []model.ApplicationItem
for _, item := range req.Items { for _, item := range req.Items {
exists, err := s.materialSvc.Exists(organizationID, item.MaterialID) exists, err := s.materialSvc.Exists(organizationID, item.MaterialID)
@ -49,11 +54,13 @@ func (s *ApplicationService) Create(organizationID, userID int64, req *model.Cre
}) })
} }
// 申请归属取可信上下文里的 organizationID,不取当前用户的默认公司。
app := &model.MaterialApplication{ app := &model.MaterialApplication{
UserID: userID, OrganizationID: organizationID,
Purpose: req.Purpose, UserID: userID,
Status: model.ApplicationStatusPending, Purpose: req.Purpose,
Items: items, Status: model.ApplicationStatusPending,
Items: items,
} }
if err := s.appRepo.Create(app); err != nil { if err := s.appRepo.Create(app); err != nil {
@ -61,19 +68,19 @@ func (s *ApplicationService) Create(organizationID, userID int64, req *model.Cre
} }
// Get full details // Get full details
return s.appRepo.GetByID(app.ID) return s.appRepo.GetByID(organizationID, app.ID)
} }
func (s *ApplicationService) GetByID(id int64) (*model.MaterialApplication, error) { func (s *ApplicationService) GetByID(organizationID, id int64) (*model.MaterialApplication, error) {
app, err := s.appRepo.GetByID(id) app, err := s.appRepo.GetByID(organizationID, id)
if err != nil { if err != nil {
return nil, ErrApplicationNotFound return nil, ErrApplicationNotFound
} }
return app, nil return app, nil
} }
func (s *ApplicationService) ListByUser(userID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) { func (s *ApplicationService) ListByUser(organizationID, userID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
apps, total, err := s.appRepo.ListByUserID(userID, status, limit, offset) apps, total, err := s.appRepo.ListByUserID(organizationID, userID, status, limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -83,8 +90,8 @@ func (s *ApplicationService) ListByUser(userID int64, status string, limit, offs
return apps, total, nil return apps, total, nil
} }
func (s *ApplicationService) ListAll(status string, limit, offset int) ([]model.MaterialApplication, int, error) { func (s *ApplicationService) ListAll(organizationID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
apps, total, err := s.appRepo.ListAllApplications(status, limit, offset) apps, total, err := s.appRepo.ListAllApplications(organizationID, status, limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -95,16 +102,31 @@ func (s *ApplicationService) ListAll(status string, limit, offset int) ([]model.
} }
func (s *ApplicationService) Approve(organizationID, id int64, approverID int64, autoOutbound bool) error { func (s *ApplicationService) Approve(organizationID, id int64, approverID int64, autoOutbound bool) error {
app, err := s.appRepo.GetByID(id) tx, err := repository.BeginTx()
if err != nil { if err != nil {
return ErrApplicationNotFound return err
} }
// 只要没走到提交就一定回滚,避免锁住的行和部分写入残留。
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
// 在同一事务内 SELECT ... FOR UPDATE 锁定当前公司的申请;别家公司或不存在命中
// sql.ErrNoRows,回 404。锁下的状态才是判定依据,并发审批在这里串行化。
app, err := s.appRepo.GetByIDForUpdateTx(tx, organizationID, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrApplicationNotFound
}
return err
}
if app.Status != model.ApplicationStatusPending { if app.Status != model.ApplicationStatusPending {
return ErrApplicationNotPending return ErrApplicationNotPending
} }
// Auto outbound if requested
if autoOutbound { if autoOutbound {
// 只有自动出库才会改变库存,才需要解析本公司启用的仓库、库位并做库存检查。 // 只有自动出库才会改变库存,才需要解析本公司启用的仓库、库位并做库存检查。
// 普通审批(不扣库存)沿用已有规则:只要求公司授权和相关物料合法,不要求仓库配置。 // 普通审批(不扣库存)沿用已有规则:只要求公司授权和相关物料合法,不要求仓库配置。
@ -112,71 +134,54 @@ func (s *ApplicationService) Approve(organizationID, id int64, approverID int64,
if err != nil { if err != nil {
return err return err
} }
for _, item := range app.Items {
stock, err := s.stockRepo.GetStock(scope, item.MaterialID)
if err != nil {
return ErrMaterialNotFound
}
if stock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
return ErrInsufficientStock
}
}
// Start transaction
tx, err := repository.BeginTx()
if err != nil {
return err
}
// 这里不能靠 err 判断是否回滚:事务内每个 if 里的 err 都是 := 声明的局部变量,
// 闭包捕获到的外层 err 始终为 nil,任何一步失败都不会回滚,事务和它持有的行锁
// 会一直挂在连接上不释放。改用标志位,只要没走到提交就一定回滚。
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
// Get approver name for handler // Get approver name for handler
approverName, _ := s.appRepo.GetApproverName(approverID) approverName, _ := s.appRepo.GetApproverName(approverID)
// Create outbound order within transaction // Create outbound order within transaction:三个库存维度显式取自本公司 scope,
// 头表这三列 NOT NULL 无默认值,不接受请求体伪造的归属。
order := &model.OutboundOrder{ order := &model.OutboundOrder{
Handler: approverName, OrganizationID: scope.OrganizationID,
Operator: approverName, WarehouseID: scope.WarehouseID,
Receiver: app.Username, LocationID: scope.LocationID,
Status: "completed", OutboundDate: time.Now(),
Handler: approverName,
Operator: approverName,
Receiver: app.Username,
Status: "completed",
Items: make([]model.OutboundItem, len(app.Items)),
} }
if err := s.appRepo.CreateOutboundOrderTx(tx, order); err != nil { for i, appItem := range app.Items {
return err order.Items[i] = model.OutboundItem{
MaterialID: appItem.MaterialID,
Quantity: appItem.Quantity,
}
} }
// Process each item within transaction. 按物料 ID 升序条件扣减,余额不足时整体回滚。 // 按物料 ID 升序条件扣减本公司余额,固定同一维度下的加锁顺序,避免并发死锁;
// 余额不存在或不足时整体回滚,不会透支、也不会锁到别家公司的余额。
itemIDs := make([]int64, len(app.Items)) itemIDs := make([]int64, len(app.Items))
for i := range app.Items { for i := range app.Items {
itemIDs[i] = app.Items[i].MaterialID itemIDs[i] = app.Items[i].MaterialID
} }
for _, idx := range materialSortedIndexes(itemIDs) { for _, idx := range materialSortedIndexes(itemIDs) {
appItem := app.Items[idx] appItem := app.Items[idx]
// Deduct stock within transaction
if err := s.stockRepo.DeductStockTx(tx, scope, appItem.MaterialID, decimal.NewFromInt(int64(appItem.Quantity))); err != nil { if err := s.stockRepo.DeductStockTx(tx, scope, appItem.MaterialID, decimal.NewFromInt(int64(appItem.Quantity))); err != nil {
if errors.Is(err, repository.ErrInsufficientStock) { if errors.Is(err, repository.ErrInsufficientStock) {
return ErrInsufficientStock return ErrInsufficientStock
} }
return err return err
} }
}
// Create outbound item within transaction // 创建带 scope 的出库单和明细。
item := &model.OutboundItem{ if err := s.outboundRepo.CreateTx(tx, order); err != nil {
OutboundOrderID: order.ID, return err
MaterialID: appItem.MaterialID, }
Quantity: appItem.Quantity,
}
if err := s.appRepo.CreateOutboundItemTx(tx, item); err != nil {
return err
}
// Create stock log within transaction // 创建带三维度的库存流水,维度与出库单、余额完全一致。
for _, idx := range materialSortedIndexes(itemIDs) {
appItem := app.Items[idx]
stockLog := &model.StockLog{ stockLog := &model.StockLog{
OrganizationID: scope.OrganizationID, OrganizationID: scope.OrganizationID,
WarehouseID: scope.WarehouseID, WarehouseID: scope.WarehouseID,
@ -187,33 +192,29 @@ func (s *ApplicationService) Approve(organizationID, id int64, approverID int64,
RelatedOrderType: "application", RelatedOrderType: "application",
RelatedOrderID: id, RelatedOrderID: id,
} }
if err := s.appRepo.CreateStockLogTx(tx, stockLog); err != nil { if err := s.stockRepo.CreateLogTx(tx, stockLog); err != nil {
return err return err
} }
} }
// Update status to approved within transaction
if err := s.appRepo.ApproveTx(tx, id, approverID); err != nil {
return err
}
// Commit transaction
if err = tx.Commit(); err != nil {
return err
}
committed = true
} else {
// Non-auto-outbound: just approve
if err := s.appRepo.Approve(id, approverID); err != nil {
return err
}
} }
// 带组织、ID 和原状态 pending 的条件更新:重复审批只允许一次成功,不产生重复出库。
if err := s.appRepo.ApproveTx(tx, organizationID, id, approverID); err != nil {
if errors.Is(err, repository.ErrApplicationStatusConflict) {
return ErrApplicationNotPending
}
return err
}
if err := tx.Commit(); err != nil {
return err
}
committed = true
return nil return nil
} }
func (s *ApplicationService) Reject(id int64, reason string) error { func (s *ApplicationService) Reject(organizationID, id int64, reason string) error {
app, err := s.appRepo.GetByID(id) app, err := s.appRepo.GetByID(organizationID, id)
if err != nil { if err != nil {
return ErrApplicationNotFound return ErrApplicationNotFound
} }
@ -222,25 +223,39 @@ func (s *ApplicationService) Reject(id int64, reason string) error {
return ErrApplicationNotPending return ErrApplicationNotPending
} }
return s.appRepo.Reject(id, reason) // 条件更新带公司、ID 和原状态,防止并发覆盖。
if err := s.appRepo.Reject(organizationID, id, reason); err != nil {
if errors.Is(err, repository.ErrApplicationStatusConflict) {
return ErrApplicationNotPending
}
return err
}
return nil
} }
func (s *ApplicationService) Cancel(id int64, userID int64) error { func (s *ApplicationService) Cancel(organizationID, id, userID int64) error {
app, err := s.appRepo.GetByID(id) app, err := s.appRepo.GetByID(organizationID, id)
if err != nil { if err != nil {
return ErrApplicationNotFound return ErrApplicationNotFound
} }
// User can only cancel their own applications // User can only cancel their own applications
if app.UserID != userID { if app.UserID != userID {
return errors.New("cannot cancel others' applications") return ErrCannotCancelOthers
} }
if app.Status != model.ApplicationStatusPending { if app.Status != model.ApplicationStatusPending {
return ErrCannotCancelApplication return ErrCannotCancelApplication
} }
return s.appRepo.Cancel(id) // 条件更新带公司、ID 和原状态,防止并发覆盖。
if err := s.appRepo.Cancel(organizationID, id); err != nil {
if errors.Is(err, repository.ErrApplicationStatusConflict) {
return ErrCannotCancelApplication
}
return err
}
return nil
} }
func (s *ApplicationService) GetMaterialStock(organizationID, materialID int64) (decimal.Decimal, error) { func (s *ApplicationService) GetMaterialStock(organizationID, materialID int64) (decimal.Decimal, error) {

View File

@ -0,0 +1,151 @@
package service
import (
"database/sql"
"encoding/json"
"errors"
"strconv"
"testing"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/testutil"
)
// seedApplicationIsolationDB 在一次性 company_isolation_test_* 库里另外播种两家公司
// (避开 init.sql 自带的公司 1):
//
// 公司 A=101:物料 101、仓库 101、库位 101、余额 10;
// 公司 B=102:物料 102、仓库 102、库位 102、余额 5。
//
// 用户 2 是申请人(两家公司都授权),用户 3 是审批人。用来验证自动出库的事务隔离。
func seedApplicationIsolationDB(t *testing.T, db *sql.DB) {
t.Helper()
mustExec := func(query string) {
if _, err := db.Exec(query); err != nil {
t.Fatalf("fixture SQL: %v\n%s", err, query)
}
}
mustExec(`INSERT INTO organizations(id,code,name) VALUES (101,'IA','A'),(102,'IB','B')`)
mustExec(`INSERT INTO users(id,username,password) VALUES (2,'applicant','x'),(3,'approver','x')`)
mustExec(`INSERT INTO material_categories(id,organization_id,code,name) VALUES (101,101,'C','C'),(102,102,'C','C')`)
mustExec(`INSERT INTO units(id,organization_id,code,name) VALUES (101,101,'U','U'),(102,102,'U','U')`)
mustExec(`INSERT INTO materials(id,organization_id,material_code,name,spec,unit,category_id,base_unit_id) VALUES (101,101,'M','M','','个',101,101),(102,102,'M','M','','个',102,102)`)
mustExec(`INSERT INTO warehouses(id,organization_id,code,name,status) VALUES (101,101,'W','W','active'),(102,102,'W','W','active')`)
mustExec(`INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES (101,101,'L','L','active'),(102,102,'L','L','active')`)
mustExec(`INSERT INTO inventory_balances(organization_id,warehouse_id,location_id,material_id,on_hand) VALUES (101,101,101,101,10),(102,102,102,102,5)`)
}
func createApplication(t *testing.T, svc *ApplicationService, organizationID, userID, materialID int64, quantity int) *model.MaterialApplication {
t.Helper()
var req model.CreateApplicationRequest
body := `{"purpose":"领用","items":[{"material_id":` +
strconv.FormatInt(materialID, 10) + `,"quantity":` + strconv.Itoa(quantity) + `}]}`
if err := json.Unmarshal([]byte(body), &req); err != nil {
t.Fatal(err)
}
app, err := svc.Create(organizationID, userID, &req)
if err != nil {
t.Fatalf("创建申请失败: %v", err)
}
return app
}
func scalarInt64(t *testing.T, db *sql.DB, query string, args ...interface{}) int64 {
t.Helper()
var v int64
if err := db.QueryRow(query, args...).Scan(&v); err != nil {
t.Fatal(err)
}
return v
}
func scalarDecimalString(t *testing.T, db *sql.DB, query string, args ...interface{}) string {
t.Helper()
var v string
if err := db.QueryRow(query, args...).Scan(&v); err != nil {
t.Fatal(err)
}
return v
}
// 端到端验证自动出库审批的事务隔离:A 审批自动出库只改 A 库存、只建 A 单据与流水;
// 重复审批只成功一次;库存不足时申请状态、出库单、库存、流水一起回滚。
func TestApproveAutoOutboundTransactionIsolation(t *testing.T) {
db := testutil.NewIsolationDB(t)
seedApplicationIsolationDB(t, db)
svc := NewApplicationService()
appA := createApplication(t, svc, 101, 2, 101, 3)
// A 审批自动出库成功。
if err := svc.Approve(101, appA.ID, 3, true); err != nil {
t.Fatalf("自动出库审批失败: %v", err)
}
// A 余额 10-3=7;B 完全不受影响。
if got := scalarDecimalString(t, db, `SELECT on_hand FROM inventory_balances WHERE organization_id=101 AND material_id=101`); got != "7.000000" {
t.Fatalf("A 余额=%s, want 7.000000", got)
}
if got := scalarDecimalString(t, db, `SELECT on_hand FROM inventory_balances WHERE organization_id=102 AND material_id=102`); got != "5.000000" {
t.Fatalf("B 余额被改动: %s", got)
}
// 出库单与流水都落在 A 公司、A 的仓库库位。
if got := scalarInt64(t, db, `SELECT COUNT(*) FROM outbound_orders WHERE organization_id=101 AND warehouse_id=101 AND location_id=101`); got != 1 {
t.Fatalf("A 出库单=%d, want 1", got)
}
if got := scalarInt64(t, db, `SELECT COUNT(*) FROM outbound_orders WHERE organization_id=102`); got != 0 {
t.Fatalf("B 出现了出库单: %d", got)
}
if got := scalarInt64(t, db, `SELECT COUNT(*) FROM stock_logs WHERE organization_id=101 AND type='out' AND related_order_type='application'`); got != 1 {
t.Fatalf("A 出库流水=%d, want 1", got)
}
if got := scalarInt64(t, db, `SELECT COUNT(*) FROM stock_logs WHERE organization_id=102`); got != 0 {
t.Fatalf("B 出现了流水: %d", got)
}
if got := scalarInt64(t, db, `SELECT COUNT(*) FROM material_applications WHERE id=? AND status='approved'`, appA.ID); got != 1 {
t.Fatalf("申请状态未变为 approved")
}
// 重复审批只成功一次:状态已非 pending,且不产生第二张出库单、不重复扣减。
if err := svc.Approve(101, appA.ID, 3, true); !errors.Is(err, ErrApplicationNotPending) {
t.Fatalf("重复审批必须 ErrApplicationNotPending, got %v", err)
}
if got := scalarInt64(t, db, `SELECT COUNT(*) FROM outbound_orders WHERE organization_id=101`); got != 1 {
t.Fatalf("重复审批产生了第二张出库单: %d", got)
}
if got := scalarDecimalString(t, db, `SELECT on_hand FROM inventory_balances WHERE organization_id=101 AND material_id=101`); got != "7.000000" {
t.Fatalf("重复审批重复扣减: %s", got)
}
// 库存不足:余额只要 7,却申请 999,审批必须整体回滚。
appShort := createApplication(t, svc, 101, 2, 101, 999)
if err := svc.Approve(101, appShort.ID, 3, true); !errors.Is(err, ErrInsufficientStock) {
t.Fatalf("库存不足必须 ErrInsufficientStock, got %v", err)
}
if got := scalarInt64(t, db, `SELECT COUNT(*) FROM material_applications WHERE id=? AND status='pending'`, appShort.ID); got != 1 {
t.Fatalf("库存不足后申请状态应保持 pending")
}
if got := scalarInt64(t, db, `SELECT COUNT(*) FROM outbound_orders WHERE organization_id=101`); got != 1 {
t.Fatalf("库存不足回滚不彻底,出库单=%d, want 1", got)
}
if got := scalarDecimalString(t, db, `SELECT on_hand FROM inventory_balances WHERE organization_id=101 AND material_id=101`); got != "7.000000" {
t.Fatalf("库存不足回滚不彻底,余额=%s", got)
}
}
// 新建申请验证全部物料属于当前公司:申请 B 的物料返回 ErrMaterialNotFound,且不落库。
func TestCreateApplicationRejectsCrossCompanyMaterial(t *testing.T) {
db := testutil.NewIsolationDB(t)
seedApplicationIsolationDB(t, db)
svc := NewApplicationService()
var req model.CreateApplicationRequest
if err := json.Unmarshal([]byte(`{"purpose":"领用","items":[{"material_id":102,"quantity":1}]}`), &req); err != nil {
t.Fatal(err)
}
if _, err := svc.Create(101, 2, &req); !errors.Is(err, ErrMaterialNotFound) {
t.Fatalf("跨公司物料必须 ErrMaterialNotFound, got %v", err)
}
if got := scalarInt64(t, db, `SELECT COUNT(*) FROM material_applications`); got != 0 {
t.Fatalf("非法申请落库了: %d", got)
}
}

View File

@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"testing" "testing"
"time" "time"
@ -8,19 +9,28 @@ import (
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
) )
func applicationHeadColumns() []string {
return []string{"id", "organization_id", "user_id", "purpose", "status", "rejection_reason", "approved_by", "created_at", "updated_at", "username"}
}
func applicationItemColumns() []string {
return []string{"id", "application_id", "material_id", "quantity", "material_code", "name"}
}
// 普通审批(不扣库存)沿用已有规则:只要求公司授权、状态待审和相关物料合法,公司没有 // 普通审批(不扣库存)沿用已有规则:只要求公司授权、状态待审和相关物料合法,公司没有
// 启用仓库也必须能审批,不能因为无法解析库存维度而返回 ErrInventoryScopeUnavailable。 // 启用仓库也必须能审批,不能因为无法解析库存维度而返回 ErrInventoryScopeUnavailable。
// 这里只登记审批必需的查询;若代码先解析仓库 scope,sqlmock 会因未预期语句失败。 // 这里只登记审批必需的查询;若代码解析仓库 scope,sqlmock 会因未预期语句失败。
func TestApproveWithoutAutoOutboundDoesNotRequireInventoryScope(t *testing.T) { func TestApproveWithoutAutoOutboundDoesNotRequireInventoryScope(t *testing.T) {
mock := outboundMock(t) mock := outboundMock(t)
now := time.Now() now := time.Now()
mock.ExpectQuery("SELECT a.id, a.user_id, a.purpose").WithArgs(int64(1)).WillReturnRows( mock.ExpectBegin()
sqlmock.NewRows([]string{"id", "user_id", "purpose", "status", "rejection_reason", "approved_by", "created_at", "updated_at", "username"}). mock.ExpectQuery("SELECT a.id, a.organization_id, a.user_id").WithArgs(int64(1), int64(1)).WillReturnRows(
AddRow(1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice")) sqlmock.NewRows(applicationHeadColumns()).
mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1)).WillReturnRows( AddRow(1, 1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice"))
sqlmock.NewRows([]string{"id", "application_id", "material_id", "quantity", "material_code", "name"}). mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1), int64(1)).WillReturnRows(
AddRow(11, 1, 7, 2, "M-1", "Material")) sqlmock.NewRows(applicationItemColumns()).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)) mock.ExpectExec("UPDATE material_applications SET status").WithArgs(model.ApplicationStatusApproved, int64(9), int64(1), int64(1), model.ApplicationStatusPending).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
if err := NewApplicationService().Approve(1, 1, 9, false); err != nil { if err := NewApplicationService().Approve(1, 1, 9, false); err != nil {
t.Fatalf("普通审批不应要求仓库配置,got %v", err) t.Fatalf("普通审批不应要求仓库配置,got %v", err)
@ -29,3 +39,81 @@ func TestApproveWithoutAutoOutboundDoesNotRequireInventoryScope(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
// 自动出库审批在同一事务内解析本公司 scope、条件扣减余额、创建带三维度的出库单和流水,
// 并带原状态条件改申请状态;所有 SQL 都必须带对组织与维度参数。
func TestApproveAutoOutboundWritesScopeAndDeductsStock(t *testing.T) {
mock := outboundMock(t)
now := time.Now()
mock.ExpectBegin()
mock.ExpectQuery("SELECT a.id, a.organization_id, a.user_id").WithArgs(int64(1), int64(1)).WillReturnRows(
sqlmock.NewRows(applicationHeadColumns()).
AddRow(1, 1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice"))
mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1), int64(1)).WillReturnRows(
sqlmock.NewRows(applicationItemColumns()).AddRow(11, 1, 7, 2, "M-1", "Material"))
// ResolveInventoryScope:公司 1 的启用仓库 10、库位 20。
mock.ExpectQuery("SELECT w.id, w.organization_id").WithArgs(int64(1)).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"}).
AddRow(10, 1, "W", "仓库", "active", 20, "L", "库位", "active"))
mock.ExpectQuery("SELECT username FROM users").WithArgs(int64(9)).WillReturnRows(sqlmock.NewRows([]string{"username"}).AddRow("approver"))
// 条件扣减带 scope 的三维度,余额不足会返回 0 行。
mock.ExpectExec("UPDATE inventory_balances SET on_hand = on_hand").WithArgs("2", int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
// 出库单头表显式写入公司、仓库、库位。
mock.ExpectExec("INSERT INTO outbound_orders").WithArgs(int64(1), int64(10), int64(20), sqlmock.AnyArg(), "approver", "approver", "alice", "").WillReturnResult(sqlmock.NewResult(5, 1))
mock.ExpectExec("UPDATE outbound_orders SET outbound_no").WithArgs("OUT-00000005", int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO outbound_items").WithArgs(int64(5), int64(7), 2).WillReturnResult(sqlmock.NewResult(1, 1))
// 库存流水显式写入三维度。
mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "out", 2, "application", int64(1)).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE material_applications SET status").WithArgs(model.ApplicationStatusApproved, int64(9), int64(1), int64(1), model.ApplicationStatusPending).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
if err := NewApplicationService().Approve(1, 1, 9, true); err != nil {
t.Fatalf("自动出库审批失败: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 自动出库遇到余额不足时,事务必须整体回滚:状态不落库、不出单、不生成流水。
func TestApproveAutoOutboundRollsBackWhenStockInsufficient(t *testing.T) {
mock := outboundMock(t)
now := time.Now()
mock.ExpectBegin()
mock.ExpectQuery("SELECT a.id, a.organization_id, a.user_id").WithArgs(int64(1), int64(1)).WillReturnRows(
sqlmock.NewRows(applicationHeadColumns()).
AddRow(1, 1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice"))
mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1), int64(1)).WillReturnRows(
sqlmock.NewRows(applicationItemColumns()).AddRow(11, 1, 7, 2, "M-1", "Material"))
mock.ExpectQuery("SELECT w.id, w.organization_id").WithArgs(int64(1)).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"}).
AddRow(10, 1, "W", "仓库", "active", 20, "L", "库位", "active"))
mock.ExpectQuery("SELECT username FROM users").WithArgs(int64(9)).WillReturnRows(sqlmock.NewRows([]string{"username"}).AddRow("approver"))
// 条件扣减影响 0 行:余额不存在或不足,必须整体回滚。
mock.ExpectExec("UPDATE inventory_balances SET on_hand = on_hand").WithArgs("2", int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectRollback()
err := NewApplicationService().Approve(1, 1, 9, true)
if !errors.Is(err, ErrInsufficientStock) {
t.Fatalf("got %v, want ErrInsufficientStock", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 别家公司的申请在锁定阶段命中空行,回 ErrApplicationNotFound,不解析 scope、不落任何单据。
func TestApproveCrossOrganizationReturnsNotFound(t *testing.T) {
mock := outboundMock(t)
mock.ExpectBegin()
mock.ExpectQuery("SELECT a.id, a.organization_id, a.user_id").WithArgs(int64(9), int64(1)).WillReturnRows(sqlmock.NewRows(applicationHeadColumns()))
mock.ExpectRollback()
err := NewApplicationService().Approve(1, 9, 3, true)
if !errors.Is(err, ErrApplicationNotFound) {
t.Fatalf("got %v, want ErrApplicationNotFound", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,72 @@
package testutil
import (
"bytes"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
"github.com/wmms/wmms/pkg/database"
)
// NewIsolationDB 在显式配置的 MySQL 服务器(TEST_DB_DSN 指向 company_isolation_test*)上
// 创建一个一次性的库,载入 scripts/init.sql 的空库结构,并把 database.DB 指向它;测试结束
// 自动删库并恢复。与 repository 侧的同名测试集共用同一台服务器与同一套空库结构,
// 让 Service 层的事务场景能直接跑真实 MySQL。未配置 TEST_DB_DSN 时跳过。
func NewIsolationDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DB_DSN")
if dsn == "" {
t.Skip("real MySQL integration requires TEST_DB_DSN")
}
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(cfg.DBName, "company_isolation_test") {
t.Fatal("TEST_DB_DSN must name a company_isolation_test schema")
}
cfg.DBName = ""
cfg.MultiStatements = true
cfg.ParseTime = true
admin, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
t.Fatal(err)
}
name := fmt.Sprintf("company_isolation_test_%d", time.Now().UnixNano())
if _, err = admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
admin.Close()
t.Fatal(err)
}
cfg.DBName = name
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
t.Fatal(err)
}
original := database.DB
database.DB = db
t.Cleanup(func() { database.DB = original; db.Close(); admin.Exec("DROP DATABASE `" + name + "`"); admin.Close() })
content, err := os.ReadFile(filepath.Join("..", "..", "scripts", "init.sql"))
if err != nil {
t.Fatal(err)
}
// init.sql 可能带 UTF-8 BOM,按字节剥掉,避免第一行 CREATE TABLE 解析失败。
content = bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF})
var lines []string
for _, line := range strings.Split(string(content), "\n") {
if strings.HasPrefix(line, "CREATE DATABASE") || strings.HasPrefix(line, "USE ") {
continue
}
lines = append(lines, line)
}
if _, err = db.Exec(strings.Join(lines, "\n")); err != nil {
t.Fatalf("initialize scripts/init.sql: %v", err)
}
return db
}