feat: 按公司隔离物料申请与审批出库
This commit is contained in:
parent
b0cab477db
commit
a1bc0539dc
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
123
internal/handler/application_organization_test.go
Normal file
123
internal/handler/application_organization_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
184
internal/repository/application_organization_test.go
Normal file
184
internal/repository/application_organization_test.go
Normal 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 的旧库必须报升级错误")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
151
internal/service/application_organization_db_test.go
Normal file
151
internal/service/application_organization_db_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
internal/testutil/isolation.go
Normal file
72
internal/testutil/isolation.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user