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)
}
// Initialize applications table
// 申请表的数据库结构统一由 scripts/init.sql(空库)或增量 SQL(升级)管理;
// 服务启动只校验运行所需的组织列是否已到位,缺列时报明确的升级错误,不再建表。
appRepo := repository.NewApplicationRepository()
if err := appRepo.InitApplicationTable(); err != nil {
log.Fatalf("Failed to initialize applications table: %v", err)
if err := appRepo.CheckApplicationSchema(); err != nil {
log.Fatalf("Failed to verify applications schema: %v", err)
}
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 {
response.InternalError(c, err.Error())
return
@ -1431,6 +1436,11 @@ func (h *Handler) ListApplications(c *gin.Context) {
return
}
orgID, ok := organizationID(c)
if !ok {
return
}
status := c.Query("status")
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 {
response.InternalError(c, err.Error())
return
@ -1464,9 +1474,18 @@ func (h *Handler) CancelApplication(c *gin.Context) {
return
}
orgID, ok := organizationID(c)
if !ok {
return
}
userID := middleware.GetCurrentUserID(c)
if err := h.appSvc.Cancel(id, userID); err != nil {
if err.Error() == "cannot cancel others' applications" || err.Error() == "cannot cancel application" {
if err := h.appSvc.Cancel(orgID, id, userID); err != nil {
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())
return
}
@ -1534,18 +1553,23 @@ func (h *Handler) RejectApplication(c *gin.Context) {
return
}
orgID, ok := organizationID(c)
if !ok {
return
}
var req model.RejectApplicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "rejection reason is required")
return
}
if err := h.appSvc.Reject(id, req.Reason); err != nil {
if err == service.ErrApplicationNotFound {
if err := h.appSvc.Reject(orgID, id, req.Reason); err != nil {
if errors.Is(err, service.ErrApplicationNotFound) {
response.NotFound(c, "application not found")
return
}
if err == service.ErrApplicationNotPending {
if errors.Is(err, service.ErrApplicationNotPending) {
response.BadRequest(c, "application is not pending")
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

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

View File

@ -2,11 +2,18 @@ package repository
import (
"database/sql"
"errors"
"fmt"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
// ErrApplicationStatusConflict 表示带原状态条件的 UPDATE 未命中任何行:申请不属于当前
// 公司,或状态已被并发请求改变(不再是 pending)。调用方据此回 404 或"状态已变更",
// 而不是把并发覆盖当成成功。
var ErrApplicationStatusConflict = errors.New("application status conflict")
type ApplicationRepository struct{}
func NewApplicationRepository() *ApplicationRepository {
@ -20,8 +27,9 @@ func (r *ApplicationRepository) Create(app *model.MaterialApplication) error {
}
defer tx.Rollback()
query := `INSERT INTO material_applications (user_id, purpose, status) VALUES (?, ?, ?)`
result, err := tx.Exec(query, app.UserID, app.Purpose, app.Status)
// organization_id 由服务端从可信上下文写入,不接受请求体伪造的归属。
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 {
return err
}
@ -45,16 +53,21 @@ func (r *ApplicationRepository) Create(app *model.MaterialApplication) error {
return tx.Commit()
}
func (r *ApplicationRepository) GetByID(id int64) (*model.MaterialApplication, error) {
query := `SELECT a.id, a.user_id, a.purpose, a.status, a.rejection_reason, a.approved_by, a.created_at, a.updated_at,
// getByID 按公司读取申请头表加明细;lock=true 时用 FOR UPDATE 锁定当前公司的原申请,
// 供审批事务在同一把锁下判定状态并落库存。别家公司的申请在这里命中 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
FROM material_applications a
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
err := database.DB.QueryRow(query, id).Scan(
&app.ID, &app.UserID, &app.Purpose, &app.Status,
err := q.QueryRow(query, id, organizationID).Scan(
&app.ID, &app.OrganizationID, &app.UserID, &app.Purpose, &app.Status,
&app.RejectionReason, &app.ApprovedBy, &app.CreatedAt, &app.UpdatedAt,
&app.Username,
)
@ -62,8 +75,7 @@ func (r *ApplicationRepository) GetByID(id int64) (*model.MaterialApplication, e
return nil, err
}
// Fetch items
app.Items, err = r.GetApplicationItems(id)
app.Items, err = r.getApplicationItems(q, organizationID, app.ID)
if err != nil {
return nil, err
}
@ -72,13 +84,16 @@ func (r *ApplicationRepository) GetByID(id int64) (*model.MaterialApplication, e
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
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 = ?`
rows, err := database.DB.Query(query, applicationID)
rows, err := q.Query(query, organizationID, applicationID)
if err != nil {
return nil, err
}
@ -93,13 +108,32 @@ func (r *ApplicationRepository) GetApplicationItems(applicationID int64) ([]mode
}
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
countQuery := `SELECT COUNT(*) FROM material_applications a WHERE 1=1`
var countArgs []interface{}
countQuery := `SELECT COUNT(*) FROM material_applications a WHERE a.organization_id = ?`
countArgs := []interface{}{organizationID}
if status != "" {
countQuery += " AND a.status = ?"
countArgs = append(countArgs, status)
@ -116,13 +150,13 @@ func (r *ApplicationRepository) ListAll(status string, userID int64, limit, offs
}
// 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
FROM material_applications a
JOIN users u ON a.user_id = u.id
WHERE 1=1`
WHERE a.organization_id = ?`
var args []interface{}
args := []interface{}{organizationID}
if status != "" {
query += " AND a.status = ?"
args = append(args, status)
@ -144,7 +178,7 @@ func (r *ApplicationRepository) ListAll(status string, userID int64, limit, offs
for rows.Next() {
var app model.MaterialApplication
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.Username,
)
@ -152,142 +186,71 @@ func (r *ApplicationRepository) ListAll(status string, userID int64, limit, offs
return nil, 0, err
}
// 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 {
return nil, 0, err
}
app.ApplicationNo = model.OrderNo("AP", app.ID)
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) {
return r.ListAll(status, userID, limit, offset)
func (r *ApplicationRepository) ListByUserID(organizationID, userID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
return r.ListAll(organizationID, status, userID, limit, offset)
}
func (r *ApplicationRepository) ListAllApplications(status string, limit, offset int) ([]model.MaterialApplication, int, error) {
return r.ListAll(status, 0, limit, offset)
func (r *ApplicationRepository) ListAllApplications(organizationID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
return r.ListAll(organizationID, status, 0, limit, offset)
}
func (r *ApplicationRepository) UpdateStatus(id int64, status string) error {
query := `UPDATE material_applications SET status = ?, updated_at = NOW() WHERE id = ?`
_, err := database.DB.Exec(query, status, id)
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)
// applicationUpdateResult 把条件 UPDATE 的"0 影响行"翻译成 ErrApplicationStatusConflict:
// 公司、ID 或原状态任一不匹配都算冲突,调用方回 404 或"状态已变更"。
func applicationUpdateResult(result sql.Result, err error) error {
if err != nil {
return err
}
id, err := result.LastInsertId()
affected, err := result.RowsAffected()
if err != nil {
return err
}
order.ID = id
order.OutboundNo = model.OrderNo("OUT", id)
if _, err := database.DB.Exec(`UPDATE outbound_orders SET outbound_no = ? WHERE id = ?`, order.OutboundNo, id); err != nil {
return err
if affected == 0 {
return ErrApplicationStatusConflict
}
return nil
}
// outbound_orders 与 inbound_orders 一样没有 status 列,单据状态由查询层统一写死。
// 这里写入的列必须和手工出库(OutboundRepo.CreateTx)一致,多写一列会让 INSERT 直接失败。
func (r *ApplicationRepository) CreateOutboundOrderTx(tx *sql.Tx, order *model.OutboundOrder) error {
query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver) VALUES (NOW(), ?, ?, ?)`
result, err := tx.Exec(query, order.Operator, order.Handler, order.Receiver)
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
// UpdateStatus 带公司、ID 和原状态 pending 三个条件,防止并发覆盖。
func (r *ApplicationRepository) UpdateStatus(organizationID, id int64, status string) error {
query := `UPDATE material_applications SET status = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status = ?`
result, err := database.DB.Exec(query, status, organizationID, id, model.ApplicationStatusPending)
return applicationUpdateResult(result, err)
}
func (r *ApplicationRepository) CreateOutboundItem(item *model.OutboundItem) error {
query := `INSERT INTO outbound_items (outbound_order_id, material_id, quantity) VALUES (?, ?, ?)`
result, err := database.DB.Exec(query, item.OutboundOrderID, item.MaterialID, item.Quantity)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
item.ID = id
return nil
// Approve 带公司、ID 和原状态 pending 条件,重复审批只会成功一次。
func (r *ApplicationRepository) Approve(organizationID, id, approverID int64) error {
query := `UPDATE material_applications SET status = ?, approved_by = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status = ?`
result, err := database.DB.Exec(query, model.ApplicationStatusApproved, approverID, organizationID, id, model.ApplicationStatusPending)
return applicationUpdateResult(result, err)
}
func (r *ApplicationRepository) CreateOutboundItemTx(tx *sql.Tx, item *model.OutboundItem) error {
query := `INSERT INTO outbound_items (outbound_order_id, material_id, quantity) VALUES (?, ?, ?)`
result, err := tx.Exec(query, item.OutboundOrderID, item.MaterialID, item.Quantity)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
item.ID = id
return nil
// Reject 带公司、ID 和原状态 pending 条件,防并发覆盖。
func (r *ApplicationRepository) Reject(organizationID, id int64, reason string) error {
query := `UPDATE material_applications SET status = ?, rejection_reason = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status = ?`
result, err := database.DB.Exec(query, model.ApplicationStatusRejected, reason, organizationID, id, model.ApplicationStatusPending)
return applicationUpdateResult(result, err)
}
func (r *ApplicationRepository) CreateStockLog(log *model.StockLog) error {
query := `INSERT INTO stock_logs (material_id, type, quantity, related_order_type, related_order_id) VALUES (?, ?, ?, ?, ?)`
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) Cancel(organizationID, id int64) error {
return r.UpdateStatus(organizationID, id, model.ApplicationStatusCancelled)
}
func (r *ApplicationRepository) CreateStockLogTx(tx *sql.Tx, log *model.StockLog) error {
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)
if err != nil {
return 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
// ApproveTx 在审批事务内条件更新状态,只允许从 pending 转 approved;0 影响行表示状态已被
// 并发请求改走,事务必须回滚,不产生重复出库。
func (r *ApplicationRepository) ApproveTx(tx *sql.Tx, organizationID, id, approverID int64) error {
query := `UPDATE material_applications SET status = ?, approved_by = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status = ?`
result, err := tx.Exec(query, model.ApplicationStatusApproved, approverID, organizationID, id, model.ApplicationStatusPending)
return applicationUpdateResult(result, err)
}
func (r *ApplicationRepository) GetApproverName(userID int64) (string, error) {
@ -297,33 +260,24 @@ func (r *ApplicationRepository) GetApproverName(userID int64) (string, error) {
return username, err
}
func (r *ApplicationRepository) InitApplicationTable() error {
query := `
CREATE TABLE IF NOT EXISTS material_applications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
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)
// CheckApplicationSchema 在服务启动时只校验运行所需的列是否已随初始化/增量 SQL 到位,
// 不再建表。缺列时报明确的升级错误,避免旧定义在新环境创建一张缺少组织列的申请表。
func (r *ApplicationRepository) CheckApplicationSchema() error {
required := []struct{ table, column string }{
{"material_applications", "organization_id"},
}
for _, c := range required {
var count int
err := database.DB.QueryRow(
`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
c.table, c.column,
).Scan(&count)
if err != nil {
return err
}
// Create application_items table
itemsQuery := `
CREATE TABLE IF NOT EXISTS application_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
application_id BIGINT NOT NULL,
material_id BIGINT NOT NULL,
quantity INT NOT NULL,
FOREIGN KEY (application_id) REFERENCES material_applications(id) ON DELETE CASCADE,
FOREIGN KEY (material_id) REFERENCES materials(id)
)`
_, err = database.DB.Exec(itemsQuery)
return err
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)
}
}
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
import (
"database/sql"
"errors"
"time"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model"
@ -12,10 +14,12 @@ var (
ErrApplicationNotFound = errors.New("application not found")
ErrCannotCancelApplication = errors.New("cannot cancel application")
ErrApplicationNotPending = errors.New("application is not pending")
ErrCannotCancelOthers = errors.New("cannot cancel others' applications")
)
type ApplicationService struct {
appRepo *repository.ApplicationRepository
outboundRepo *repository.OutboundRepo
materialSvc *MaterialService
stockRepo *repository.StockRepo
}
@ -23,13 +27,14 @@ type ApplicationService struct {
func NewApplicationService() *ApplicationService {
return &ApplicationService{
appRepo: repository.NewApplicationRepository(),
outboundRepo: repository.NewOutboundRepo(),
materialSvc: NewMaterialService(),
stockRepo: repository.NewStockRepo(),
}
}
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
for _, item := range req.Items {
exists, err := s.materialSvc.Exists(organizationID, item.MaterialID)
@ -49,7 +54,9 @@ func (s *ApplicationService) Create(organizationID, userID int64, req *model.Cre
})
}
// 申请归属取可信上下文里的 organizationID,不取当前用户的默认公司。
app := &model.MaterialApplication{
OrganizationID: organizationID,
UserID: userID,
Purpose: req.Purpose,
Status: model.ApplicationStatusPending,
@ -61,19 +68,19 @@ func (s *ApplicationService) Create(organizationID, userID int64, req *model.Cre
}
// 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) {
app, err := s.appRepo.GetByID(id)
func (s *ApplicationService) GetByID(organizationID, id int64) (*model.MaterialApplication, error) {
app, err := s.appRepo.GetByID(organizationID, id)
if err != nil {
return nil, ErrApplicationNotFound
}
return app, nil
}
func (s *ApplicationService) ListByUser(userID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
apps, total, err := s.appRepo.ListByUserID(userID, status, limit, offset)
func (s *ApplicationService) ListByUser(organizationID, userID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
apps, total, err := s.appRepo.ListByUserID(organizationID, userID, status, limit, offset)
if err != nil {
return nil, 0, err
}
@ -83,8 +90,8 @@ func (s *ApplicationService) ListByUser(userID int64, status string, limit, offs
return apps, total, nil
}
func (s *ApplicationService) ListAll(status string, limit, offset int) ([]model.MaterialApplication, int, error) {
apps, total, err := s.appRepo.ListAllApplications(status, limit, offset)
func (s *ApplicationService) ListAll(organizationID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
apps, total, err := s.appRepo.ListAllApplications(organizationID, status, limit, offset)
if err != nil {
return nil, 0, err
}
@ -95,41 +102,11 @@ func (s *ApplicationService) ListAll(status string, limit, offset int) ([]model.
}
func (s *ApplicationService) Approve(organizationID, id int64, approverID int64, autoOutbound bool) error {
app, err := s.appRepo.GetByID(id)
if err != nil {
return ErrApplicationNotFound
}
if app.Status != model.ApplicationStatusPending {
return ErrApplicationNotPending
}
// Auto outbound if requested
if autoOutbound {
// 只有自动出库才会改变库存,才需要解析本公司启用的仓库、库位并做库存检查。
// 普通审批(不扣库存)沿用已有规则:只要求公司授权和相关物料合法,不要求仓库配置。
scope, err := ResolveInventoryScope(organizationID, true)
if err != nil {
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 {
@ -137,46 +114,74 @@ func (s *ApplicationService) Approve(organizationID, id int64, approverID int64,
}
}()
// 在同一事务内 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 {
return ErrApplicationNotPending
}
if autoOutbound {
// 只有自动出库才会改变库存,才需要解析本公司启用的仓库、库位并做库存检查。
// 普通审批(不扣库存)沿用已有规则:只要求公司授权和相关物料合法,不要求仓库配置。
scope, err := ResolveInventoryScope(organizationID, true)
if err != nil {
return err
}
// Get approver name for handler
approverName, _ := s.appRepo.GetApproverName(approverID)
// Create outbound order within transaction
// Create outbound order within transaction:三个库存维度显式取自本公司 scope,
// 头表这三列 NOT NULL 无默认值,不接受请求体伪造的归属。
order := &model.OutboundOrder{
OrganizationID: scope.OrganizationID,
WarehouseID: scope.WarehouseID,
LocationID: scope.LocationID,
OutboundDate: time.Now(),
Handler: approverName,
Operator: approverName,
Receiver: app.Username,
Status: "completed",
Items: make([]model.OutboundItem, len(app.Items)),
}
for i, appItem := range app.Items {
order.Items[i] = model.OutboundItem{
MaterialID: appItem.MaterialID,
Quantity: appItem.Quantity,
}
if err := s.appRepo.CreateOutboundOrderTx(tx, order); err != nil {
return err
}
// Process each item within transaction. 按物料 ID 升序条件扣减,余额不足时整体回滚。
// 按物料 ID 升序条件扣减本公司余额,固定同一维度下的加锁顺序,避免并发死锁;
// 余额不存在或不足时整体回滚,不会透支、也不会锁到别家公司的余额。
itemIDs := make([]int64, len(app.Items))
for i := range app.Items {
itemIDs[i] = app.Items[i].MaterialID
}
for _, idx := range materialSortedIndexes(itemIDs) {
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 errors.Is(err, repository.ErrInsufficientStock) {
return ErrInsufficientStock
}
return err
}
// Create outbound item within transaction
item := &model.OutboundItem{
OutboundOrderID: order.ID,
MaterialID: appItem.MaterialID,
Quantity: appItem.Quantity,
}
if err := s.appRepo.CreateOutboundItemTx(tx, item); err != nil {
// 创建带 scope 的出库单和明细。
if err := s.outboundRepo.CreateTx(tx, order); err != nil {
return err
}
// Create stock log within transaction
// 创建带三维度的库存流水,维度与出库单、余额完全一致。
for _, idx := range materialSortedIndexes(itemIDs) {
appItem := app.Items[idx]
stockLog := &model.StockLog{
OrganizationID: scope.OrganizationID,
WarehouseID: scope.WarehouseID,
@ -187,33 +192,29 @@ func (s *ApplicationService) Approve(organizationID, id int64, approverID int64,
RelatedOrderType: "application",
RelatedOrderID: id,
}
if err := s.appRepo.CreateStockLogTx(tx, stockLog); err != nil {
if err := s.stockRepo.CreateLogTx(tx, stockLog); err != nil {
return err
}
}
}
// Update status to approved within transaction
if err := s.appRepo.ApproveTx(tx, id, approverID); err != nil {
// 带组织、ID 和原状态 pending 的条件更新:重复审批只允许一次成功,不产生重复出库。
if err := s.appRepo.ApproveTx(tx, organizationID, id, approverID); err != nil {
if errors.Is(err, repository.ErrApplicationStatusConflict) {
return ErrApplicationNotPending
}
return err
}
// Commit transaction
if err = tx.Commit(); err != nil {
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
}
}
return nil
}
func (s *ApplicationService) Reject(id int64, reason string) error {
app, err := s.appRepo.GetByID(id)
func (s *ApplicationService) Reject(organizationID, id int64, reason string) error {
app, err := s.appRepo.GetByID(organizationID, id)
if err != nil {
return ErrApplicationNotFound
}
@ -222,25 +223,39 @@ func (s *ApplicationService) Reject(id int64, reason string) error {
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 {
app, err := s.appRepo.GetByID(id)
func (s *ApplicationService) Cancel(organizationID, id, userID int64) error {
app, err := s.appRepo.GetByID(organizationID, id)
if err != nil {
return ErrApplicationNotFound
}
// User can only cancel their own applications
if app.UserID != userID {
return errors.New("cannot cancel others' applications")
return ErrCannotCancelOthers
}
if app.Status != model.ApplicationStatusPending {
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) {

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
import (
"errors"
"testing"
"time"
@ -8,19 +9,28 @@ import (
"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。
// 这里只登记审批必需的查询;若代码先解析仓库 scope,sqlmock 会因未预期语句失败。
// 这里只登记审批必需的查询;若代码解析仓库 scope,sqlmock 会因未预期语句失败。
func TestApproveWithoutAutoOutboundDoesNotRequireInventoryScope(t *testing.T) {
mock := outboundMock(t)
now := time.Now()
mock.ExpectQuery("SELECT a.id, a.user_id, a.purpose").WithArgs(int64(1)).WillReturnRows(
sqlmock.NewRows([]string{"id", "user_id", "purpose", "status", "rejection_reason", "approved_by", "created_at", "updated_at", "username"}).
AddRow(1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice"))
mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1)).WillReturnRows(
sqlmock.NewRows([]string{"id", "application_id", "material_id", "quantity", "material_code", "name"}).
AddRow(11, 1, 7, 2, "M-1", "Material"))
mock.ExpectExec("UPDATE material_applications SET status").WithArgs(model.ApplicationStatusApproved, int64(9), int64(1)).WillReturnResult(sqlmock.NewResult(0, 1))
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.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 {
t.Fatalf("普通审批不应要求仓库配置,got %v", err)
@ -29,3 +39,81 @@ func TestApproveWithoutAutoOutboundDoesNotRequireInventoryScope(t *testing.T) {
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
}