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 }