284 lines
11 KiB
Go
284 lines
11 KiB
Go
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
|
||
}
|