material_manage_system/internal/repository/application.go

284 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
return &ApplicationRepository{}
}
func (r *ApplicationRepository) Create(app *model.MaterialApplication) error {
tx, err := database.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// 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
}
id, err := result.LastInsertId()
if err != nil {
return err
}
app.ID = id
// Insert application items
for _, item := range app.Items {
itemQuery := `INSERT INTO application_items (application_id, material_id, quantity) VALUES (?, ?, ?)`
itemResult, err := tx.Exec(itemQuery, app.ID, item.MaterialID, item.Quantity)
if err != nil {
return err
}
itemID, _ := itemResult.LastInsertId()
item.ID = itemID
}
return tx.Commit()
}
// 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 = ? AND a.organization_id = ?`
if lock {
query += ` FOR UPDATE`
}
var app model.MaterialApplication
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,
)
if err != nil {
return nil, err
}
app.Items, err = r.getApplicationItems(q, organizationID, app.ID)
if err != nil {
return nil, err
}
app.ApplicationNo = model.OrderNo("AP", app.ID)
return &app, nil
}
// 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 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 := q.Query(query, organizationID, applicationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []model.ApplicationItem
for rows.Next() {
var item model.ApplicationItem
err := rows.Scan(&item.ID, &item.ApplicationID, &item.MaterialID, &item.Quantity, &item.MaterialCode, &item.MaterialName)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
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 a.organization_id = ?`
countArgs := []interface{}{organizationID}
if status != "" {
countQuery += " AND a.status = ?"
countArgs = append(countArgs, status)
}
if userID > 0 {
countQuery += " AND a.user_id = ?"
countArgs = append(countArgs, userID)
}
var total int
err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
// Data query
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.organization_id = ?`
args := []interface{}{organizationID}
if status != "" {
query += " AND a.status = ?"
args = append(args, status)
}
if userID > 0 {
query += " AND a.user_id = ?"
args = append(args, userID)
}
query += " ORDER BY a.created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := database.DB.Query(query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var apps []model.MaterialApplication
for rows.Next() {
var app model.MaterialApplication
err := rows.Scan(
&app.ID, &app.OrganizationID, &app.UserID, &app.Purpose, &app.Status,
&app.RejectionReason, &app.ApprovedBy, &app.CreatedAt, &app.UpdatedAt,
&app.Username,
)
if err != nil {
return nil, 0, err
}
// Fetch items for each application
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, rows.Err()
}
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(organizationID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) {
return r.ListAll(organizationID, status, 0, limit, offset)
}
// applicationUpdateResult 把条件 UPDATE 的"0 影响行"翻译成 ErrApplicationStatusConflict:
// 公司、ID 或原状态任一不匹配都算冲突,调用方回 404 或"状态已变更"。
func applicationUpdateResult(result sql.Result, err error) error {
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrApplicationStatusConflict
}
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)
}
// 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)
}
// 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) Cancel(organizationID, id int64) error {
return r.UpdateStatus(organizationID, id, model.ApplicationStatusCancelled)
}
// 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) {
query := `SELECT username FROM users WHERE id = ?`
var username string
err := database.DB.QueryRow(query, userID).Scan(&username)
return username, err
}
// 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
}
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
}