更新若干功能
This commit is contained in:
1 parent
892ba5886d
commit
79d5ae6b66
45 files changed
+6755
-38
No files matched your search
@@ -0,0 +1,237 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/pkg/identity"
|
||||
"server/services/dailyreport"
|
||||
)
|
||||
|
||||
// OA 工作日报(小程序端)。与后台 /backend/oa/dailyreport/* 共用
|
||||
// services/dailyreport 的权限与校验逻辑,仅在响应包装与上传入口上有差异。
|
||||
|
||||
// dailyReportCtx 解析移动端令牌并组装日报身份。
|
||||
func (c *AppOaController) dailyReportCtx() (*dailyreport.UserContext, bool) {
|
||||
claims, err := c.oaClaims()
|
||||
if err != nil {
|
||||
code := identity.Status(err)
|
||||
c.oaErr(code, code, err.Error())
|
||||
return nil, false
|
||||
}
|
||||
u, err := dailyreport.BuildContext(
|
||||
uint64(claims.TenantId), uint64(claims.UserID), claims.Username, claims.UserType == "platform")
|
||||
if err != nil {
|
||||
code := dailyreport.StatusOf(err, 500)
|
||||
msg := err.Error()
|
||||
if code == 500 {
|
||||
msg = "操作失败,请稍后重试"
|
||||
}
|
||||
c.oaErr(code, code, msg)
|
||||
return nil, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
func (c *AppOaController) dailyReportFail(err error) {
|
||||
code := dailyreport.StatusOf(err, 500)
|
||||
msg := err.Error()
|
||||
if code == 500 {
|
||||
msg = "操作失败,请稍后重试"
|
||||
}
|
||||
c.oaErr(code, code, msg)
|
||||
}
|
||||
|
||||
func (c *AppOaController) dailyReportID() uint64 {
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
c.oaErr(400, 400, "无效ID")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// DailyReportProfile GET /api/oa/dailyreport/profile
|
||||
func (c *AppOaController) DailyReportProfile() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
teamOrgs := u.TeamOrgIDs
|
||||
if teamOrgs == nil {
|
||||
teamOrgs = []uint64{}
|
||||
}
|
||||
c.oaOk(map[string]interface{}{
|
||||
"user_id": u.UID,
|
||||
"user_name": u.Name,
|
||||
"org_id": u.OrgID,
|
||||
"org_name": u.OrgName,
|
||||
"is_admin": u.IsAdmin,
|
||||
"can_view_team": u.CanViewTeam,
|
||||
"team_org_ids": teamOrgs,
|
||||
"window_start": dailyreport.WindowStart(),
|
||||
"window_end": dailyreport.Today(),
|
||||
"backfill_days": dailyreport.BackfillDays,
|
||||
})
|
||||
}
|
||||
|
||||
// DailyReportMineList GET /api/oa/dailyreport/mine/list
|
||||
func (c *AppOaController) DailyReportMineList() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
res, err := dailyreport.ListMine(u, dailyreport.MineQuery{
|
||||
StartDate: strings.TrimSpace(c.GetString("start_date")),
|
||||
EndDate: strings.TrimSpace(c.GetString("end_date")),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(res)
|
||||
}
|
||||
|
||||
// DailyReportSave POST /api/oa/dailyreport/save
|
||||
func (c *AppOaController) DailyReportSave() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in dailyreport.SaveInput
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || json.Unmarshal(raw, &in) != nil {
|
||||
c.oaErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
item, err := dailyreport.Save(u, in)
|
||||
if err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(item)
|
||||
}
|
||||
|
||||
// DailyReportDetail GET /api/oa/dailyreport/detail/:id
|
||||
func (c *AppOaController) DailyReportDetail() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := c.dailyReportID()
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
detail, err := dailyreport.GetDetail(u, id)
|
||||
if err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(detail)
|
||||
}
|
||||
|
||||
// DailyReportDelete DELETE /api/oa/dailyreport/delete/:id
|
||||
func (c *AppOaController) DailyReportDelete() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := c.dailyReportID()
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
if err := dailyreport.Delete(u, id); err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// DailyReportTeamStatus GET /api/oa/dailyreport/team/status
|
||||
func (c *AppOaController) DailyReportTeamStatus() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
orgID, _ := c.GetInt64("org_id", 0)
|
||||
res, err := dailyreport.TeamStatus(u, dailyreport.TeamQuery{
|
||||
Date: strings.TrimSpace(c.GetString("date")),
|
||||
OrgID: uint64(orgID),
|
||||
Keyword: strings.TrimSpace(c.GetString("keyword")),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(res)
|
||||
}
|
||||
|
||||
// DailyReportTeamOrgs GET /api/oa/dailyreport/team/orgs
|
||||
func (c *AppOaController) DailyReportTeamOrgs() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orgs, err := dailyreport.TeamOrgs(u)
|
||||
if err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(map[string]interface{}{"list": orgs})
|
||||
}
|
||||
|
||||
// DailyReportComment POST /api/oa/dailyreport/comment
|
||||
func (c *AppOaController) DailyReportComment() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var payload oaDailyReportCommentPayload
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || json.Unmarshal(raw, &payload) != nil {
|
||||
c.oaErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if payload.ReportID == 0 {
|
||||
c.oaErr(400, 400, "缺少日报ID")
|
||||
return
|
||||
}
|
||||
comment, err := dailyreport.AddComment(u, payload.ReportID, payload.Content, payload.ReplyToID)
|
||||
if err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(comment)
|
||||
}
|
||||
|
||||
// DailyReportCommentDelete DELETE /api/oa/dailyreport/comment/:id
|
||||
func (c *AppOaController) DailyReportCommentDelete() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := c.dailyReportID()
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
if err := dailyreport.DeleteComment(u, id); err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// DailyReportUpload POST /api/oa/dailyreport/upload(multipart,字段名 file)
|
||||
// 复用 OA 附件上传:图片 / PDF,单个文件 ≤10MB,落入租户文件管理。
|
||||
func (c *AppOaController) DailyReportUpload() {
|
||||
c.uploadTenantImage()
|
||||
}
|
||||
@@ -348,6 +348,10 @@ func (c *BackendCrmBusinessController) ChangeStage() {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能流转阶段")
|
||||
return
|
||||
}
|
||||
if biz.Status == 3 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已废弃,请先恢复为「跟进中」再流转阶段")
|
||||
return
|
||||
}
|
||||
// 阶段未变化:直接幂等返回,不写日志
|
||||
if strings.TrimSpace(biz.Stage) == stage {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "stage": biz.Stage})
|
||||
@@ -364,6 +368,78 @@ func (c *BackendCrmBusinessController) ChangeStage() {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "stage": stage})
|
||||
}
|
||||
|
||||
// ChangeStatus POST /backend/crm/business/:id/status
|
||||
// 商机状态流转:1跟进中 / 3已废弃。
|
||||
// 「已转化项目(2)」不走本接口,须用「转项目」完成(会锁定商机)。
|
||||
func (c *BackendCrmBusinessController) ChangeStatus() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
code := identity.Status(err)
|
||||
pipelineErr(&c.Controller, code, code, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p struct {
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.Status != 1 && p.Status != 3 {
|
||||
pipelineErr(&c.Controller, 400, 400, "仅支持流转为「跟进中 / 已废弃」,转为已转化项目请使用「转项目」")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var biz models.TenantCrmBusiness
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&biz); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
|
||||
return
|
||||
}
|
||||
if biz.Locked == 1 || biz.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能流转状态")
|
||||
return
|
||||
}
|
||||
// 状态未变化:直接幂等返回,不写日志
|
||||
if biz.Status == p.Status {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "status": biz.Status})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"status": p.Status, "update_time": time.Now()}); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 2, biz.ID, "status",
|
||||
fmt.Sprintf("商机状态:%s → %s", businessStatusName(biz.Status), businessStatusName(p.Status)), claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "status": p.Status})
|
||||
}
|
||||
|
||||
// businessStatusName 商机状态文案(用于操作日志):1跟进中 / 2已转化项目 / 3已废弃
|
||||
func businessStatusName(s int8) string {
|
||||
switch s {
|
||||
case 2:
|
||||
return "已转化项目"
|
||||
case 3:
|
||||
return "已废弃"
|
||||
default:
|
||||
return "跟进中"
|
||||
}
|
||||
}
|
||||
|
||||
// businessStageName 商机阶段文案(用于操作日志):1初步接洽 … 5赢单 / 6输单
|
||||
func businessStageName(s string) string {
|
||||
switch strings.TrimSpace(s) {
|
||||
@@ -480,6 +556,10 @@ func (c *BackendCrmBusinessController) Convert() {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转化,不能重复转化")
|
||||
return
|
||||
}
|
||||
if biz.Status == 3 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已废弃,请先恢复为「跟进中」再转项目")
|
||||
return
|
||||
}
|
||||
|
||||
projectName := strings.TrimSpace(p.ProjectName)
|
||||
if projectName == "" {
|
||||
|
||||
@@ -475,6 +475,10 @@ func (c *BackendCrmClueController) Convert() {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已转化,不能重复转化")
|
||||
return
|
||||
}
|
||||
if clue.Status == 3 {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已废弃,请先恢复为「跟进中」再转化商机")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 处理客户:新建正式客户 或 关联已有客户
|
||||
customerName := strings.TrimSpace(p.Customer.CustomerName)
|
||||
|
||||
@@ -57,12 +57,15 @@ func isTenantAdmin(userID int, tenantID int) bool {
|
||||
if tu.GroupID == 0 {
|
||||
return true
|
||||
}
|
||||
// 「租户管理员」「租户用户」是 cid=2 且 tenant_id=0 的全局预置角色,
|
||||
// 只有租户自建角色才带自己的 tenant_id,因此两种归属都要接受,
|
||||
// 否则全局角色会因 tenant_id 不匹配而永远查不到角色。
|
||||
cond := orm.NewCondition().
|
||||
And("id", tu.GroupID).
|
||||
And("cid", 2).
|
||||
AndCond(orm.NewCondition().Or("tenant_id", 0).Or("tenant_id", tenantID))
|
||||
var role models.AdminRole
|
||||
if err := models.Orm.QueryTable(new(models.AdminRole)).
|
||||
Filter("id", tu.GroupID).
|
||||
Filter("cid", 2).
|
||||
Filter("tenant_id", tenantID).
|
||||
One(&role); err != nil {
|
||||
if err := models.Orm.QueryTable(new(models.AdminRole)).SetCond(cond).One(&role); err != nil {
|
||||
return false
|
||||
}
|
||||
if role.Name == "租户管理员" {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/pkg/identity"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services/dailyreport"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendOaDailyReportController OA工作日报(后台 PC 端)。
|
||||
//
|
||||
// 可见范围由 services/dailyreport 统一判定:
|
||||
// - 本人:自己的全部日报(含草稿),补写窗口内可编辑/删除;
|
||||
// - 部门主管:本人作为负责人的部门及其下级部门成员「已提交」的日报;
|
||||
// - 租户管理员:全租户日报,可删除任意日报与评论。
|
||||
type BackendOaDailyReportController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *BackendOaDailyReportController) odrClaims() (*jwtutil.Claims, error) {
|
||||
return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
|
||||
}
|
||||
|
||||
func (c *BackendOaDailyReportController) odrErr(status, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(status)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendOaDailyReportController) odrOk(data interface{}) {
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendOaDailyReportController) odrFail(err error) {
|
||||
code := dailyreport.StatusOf(err, 500)
|
||||
msg := err.Error()
|
||||
if code == 500 {
|
||||
msg = "操作失败,请稍后重试"
|
||||
}
|
||||
c.odrErr(code, code, msg)
|
||||
}
|
||||
|
||||
// odrCtx 解析令牌并组装日报身份;失败时已输出响应。
|
||||
func (c *BackendOaDailyReportController) odrCtx() (*dailyreport.UserContext, bool) {
|
||||
claims, err := c.odrClaims()
|
||||
if err != nil {
|
||||
code := identity.Status(err)
|
||||
c.odrErr(code, code, err.Error())
|
||||
return nil, false
|
||||
}
|
||||
u, err := dailyreport.BuildContext(
|
||||
uint64(claims.TenantId), uint64(claims.UserID), claims.Username, claims.UserType == "platform")
|
||||
if err != nil {
|
||||
c.odrFail(err)
|
||||
return nil, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
func (c *BackendOaDailyReportController) odrIDParam(name string) uint64 {
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64)
|
||||
if id == 0 {
|
||||
c.odrErr(400, 400, "无效ID")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Profile GET /backend/oa/dailyreport/profile
|
||||
// 返回当前用户身份、补写窗口与团队可见性,供页面决定是否显示「团队日报」Tab。
|
||||
func (c *BackendOaDailyReportController) Profile() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
teamOrgs := u.TeamOrgIDs
|
||||
if teamOrgs == nil {
|
||||
teamOrgs = []uint64{}
|
||||
}
|
||||
c.odrOk(map[string]interface{}{
|
||||
"user_id": u.UID,
|
||||
"user_name": u.Name,
|
||||
"org_id": u.OrgID,
|
||||
"org_name": u.OrgName,
|
||||
"is_admin": u.IsAdmin,
|
||||
"can_view_team": u.CanViewTeam,
|
||||
"team_org_ids": teamOrgs,
|
||||
"window_start": dailyreport.WindowStart(),
|
||||
"window_end": dailyreport.Today(),
|
||||
"backfill_days": dailyreport.BackfillDays,
|
||||
})
|
||||
}
|
||||
|
||||
// MineList GET /backend/oa/dailyreport/mine/list
|
||||
// 我的日报分页列表,支持 start_date/end_date 筛选。
|
||||
func (c *BackendOaDailyReportController) MineList() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
res, err := dailyreport.ListMine(u, dailyreport.MineQuery{
|
||||
StartDate: strings.TrimSpace(c.GetString("start_date")),
|
||||
EndDate: strings.TrimSpace(c.GetString("end_date")),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(res)
|
||||
}
|
||||
|
||||
// Save POST /backend/oa/dailyreport/save
|
||||
// 新建或更新日报;submit=true 提交,false 存草稿。
|
||||
func (c *BackendOaDailyReportController) Save() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in dailyreport.SaveInput
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || json.Unmarshal(raw, &in) != nil {
|
||||
c.odrErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
item, err := dailyreport.Save(u, in)
|
||||
if err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(item)
|
||||
}
|
||||
|
||||
// Detail GET /backend/oa/dailyreport/detail/:id
|
||||
func (c *BackendOaDailyReportController) Detail() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := c.odrIDParam(":id")
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
detail, err := dailyreport.GetDetail(u, id)
|
||||
if err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(detail)
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/oa/dailyreport/delete/:id
|
||||
func (c *BackendOaDailyReportController) Delete() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := c.odrIDParam(":id")
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
if err := dailyreport.Delete(u, id); err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// TeamStatus GET /backend/oa/dailyreport/team/status
|
||||
// 团队日报汇总:date/org_id/keyword/page/pageSize。
|
||||
func (c *BackendOaDailyReportController) TeamStatus() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
orgID, _ := c.GetInt64("org_id", 0)
|
||||
res, err := dailyreport.TeamStatus(u, dailyreport.TeamQuery{
|
||||
Date: strings.TrimSpace(c.GetString("date")),
|
||||
OrgID: uint64(orgID),
|
||||
Keyword: strings.TrimSpace(c.GetString("keyword")),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(res)
|
||||
}
|
||||
|
||||
// TeamOrgs GET /backend/oa/dailyreport/team/orgs
|
||||
func (c *BackendOaDailyReportController) TeamOrgs() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orgs, err := dailyreport.TeamOrgs(u)
|
||||
if err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(map[string]interface{}{"list": orgs})
|
||||
}
|
||||
|
||||
type oaDailyReportCommentPayload struct {
|
||||
ReportID uint64 `json:"report_id"`
|
||||
Content string `json:"content"`
|
||||
ReplyToID uint64 `json:"reply_to_id"`
|
||||
}
|
||||
|
||||
// Comment POST /backend/oa/dailyreport/comment
|
||||
func (c *BackendOaDailyReportController) Comment() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var payload oaDailyReportCommentPayload
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || json.Unmarshal(raw, &payload) != nil {
|
||||
c.odrErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if payload.ReportID == 0 {
|
||||
c.odrErr(400, 400, "缺少日报ID")
|
||||
return
|
||||
}
|
||||
comment, err := dailyreport.AddComment(u, payload.ReportID, payload.Content, payload.ReplyToID)
|
||||
if err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(comment)
|
||||
}
|
||||
|
||||
// DeleteComment DELETE /backend/oa/dailyreport/comment/:id
|
||||
func (c *BackendOaDailyReportController) DeleteComment() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := c.odrIDParam(":id")
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
if err := dailyreport.DeleteComment(u, id); err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(map[string]interface{}{"id": id})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"server/services/wechatmini"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 官网前台只读接口:小程序公开信息
|
||||
//
|
||||
// 供 website SPA / H5 / 其他端调取平台端「小程序配置」里维护的
|
||||
// 小程序名称、AppID 与二维码图片地址,无需登录。
|
||||
// GET /site/miniapp 已启用小程序列表(含 qrcode_url)
|
||||
//
|
||||
// 说明:qrcode_url 形如 /uploads/xxx(本地存储)或 CDN 绝对地址,
|
||||
// 相对地址由前端拼接接口域名。
|
||||
// =============================================================
|
||||
|
||||
// IndexMiniAppController 小程序公开信息
|
||||
type IndexMiniAppController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// List GET /site/miniapp 已启用小程序列表(含二维码图片地址)
|
||||
func (c *IndexMiniAppController) List() {
|
||||
list, err := wechatmini.ListEnabledApps()
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(500)
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取小程序列表失败"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"list": list},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
@@ -155,6 +155,7 @@ func (c *PlatformWechatMiniController) SaveApp() {
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
QrcodeURL string `json:"qrcode_url"`
|
||||
Enabled int `json:"enabled"`
|
||||
AutoBind int `json:"auto_bind"`
|
||||
Remark string `json:"remark"`
|
||||
@@ -177,6 +178,7 @@ func (c *PlatformWechatMiniController) SaveApp() {
|
||||
Name: p.Name,
|
||||
AppID: p.AppID,
|
||||
AppSecret: p.AppSecret,
|
||||
QrcodeURL: p.QrcodeURL,
|
||||
Enabled: p.Enabled == 1,
|
||||
AutoBind: p.AutoBind == 1,
|
||||
Remark: p.Remark,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- =============================================================
|
||||
-- OA工作日报:删除改为软删除(已建表环境执行)
|
||||
--
|
||||
-- 背景:原设计为物理删除(日报被删时同步删除评论),现改为软删除。
|
||||
-- 日报:is_deleted=0 未删除;删除时写入记录 ID 作为删除批次标记。
|
||||
-- 评论:is_deleted=0 未删除;删除时置 1。
|
||||
--
|
||||
-- 为什么唯一键要加 is_deleted:
|
||||
-- 原唯一键 (tid, user_id, report_date) 会让「删除后同一天重新填写」与
|
||||
-- 历史软删记录冲突。把 is_deleted 加入唯一键后,删除时写入记录 ID
|
||||
-- (每条记录 ID 唯一),同一天可反复「填写→删除→重新填写」。
|
||||
--
|
||||
-- 适用:已执行过 create_oa_dailyreport.sql 的环境;
|
||||
-- 新库直接执行 create_oa_dailyreport.sql 即为软删除结构,无需本脚本。
|
||||
-- 幂等:重复执行安全。
|
||||
-- =============================================================
|
||||
|
||||
USE `go-platform`;
|
||||
|
||||
-- 1) is_deleted 扩为 BIGINT UNSIGNED,用于存放删除批次(记录 ID)。MODIFY 幂等。
|
||||
ALTER TABLE `yz_backend_oa_daily_report`
|
||||
MODIFY COLUMN `is_deleted` BIGINT UNSIGNED NOT NULL DEFAULT 0
|
||||
COMMENT '软删除标记:0=未删除;删除时写入记录ID(避免唯一键冲突)';
|
||||
|
||||
-- 2) 唯一键加入 is_deleted(先判断当前索引列,避免重复执行报 1091)
|
||||
SET @idx_cols := (
|
||||
SELECT GROUP_CONCAT(`COLUMN_NAME` ORDER BY `SEQ_IN_INDEX`)
|
||||
FROM `information_schema`.`STATISTICS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'yz_backend_oa_daily_report'
|
||||
AND `INDEX_NAME` = 'uk_tid_user_date'
|
||||
);
|
||||
SET @ddl := IF(
|
||||
@idx_cols = 'tid,user_id,report_date,is_deleted',
|
||||
'SELECT ''uk_tid_user_date 已包含 is_deleted,跳过'' AS msg',
|
||||
'ALTER TABLE `yz_backend_oa_daily_report` DROP INDEX `uk_tid_user_date`, ADD UNIQUE KEY `uk_tid_user_date` (`tid`, `user_id`, `report_date`, `is_deleted`)'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 3) 核对结果
|
||||
SHOW INDEX FROM `yz_backend_oa_daily_report` WHERE `Key_name` = 'uk_tid_user_date';
|
||||
SHOW FULL COLUMNS FROM `yz_backend_oa_daily_report` WHERE `Field` = 'is_deleted';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 微信小程序多应用表补充「小程序码」字段
|
||||
-- 平台端「小程序配置 - 新增/编辑小程序」上传并保存二维码图片地址,
|
||||
-- 官网 / H5 等其他端通过 GET /site/miniapp 读取 qrcode_url 展示。
|
||||
-- 存储格式:上传后的访问地址(本地为 /uploads/...,七牛为 CDN 绝对地址)
|
||||
ALTER TABLE `yz_platform_wechat_mini_app`
|
||||
ADD COLUMN `qrcode_url` varchar(512) NOT NULL DEFAULT '' COMMENT '小程序码图片地址(供官网/H5等其他端调取)' AFTER `app_secret`;
|
||||
|
||||
-- 结果校验
|
||||
SELECT id, name, app_id, enabled, qrcode_url
|
||||
FROM yz_platform_wechat_mini_app
|
||||
WHERE delete_time IS NULL;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- =============================================================
|
||||
-- OA 工作日报 —— 表结构
|
||||
--
|
||||
-- 对应代码:
|
||||
-- models/oa_daily_report.go
|
||||
-- services/oauth/dailyreport_service.go
|
||||
-- controllers/backend_oa_dailyreport.go(后台 PC 端)
|
||||
-- controllers/app_oa_dailyreport.go(小程序端)
|
||||
--
|
||||
-- 设计要点:
|
||||
-- 1. 每人每天一条日报,用 (tid, user_id, report_date, is_deleted) 唯一键约束;
|
||||
-- user_id 存 SystemTenantUser.uid(不是 JWT 里的 identity_id)。
|
||||
-- 2. 删除为软删除:is_deleted=0 表示未删除;删除时写入记录 ID 作为删除批次标记,
|
||||
-- 使唯一键在「删除后同一天重新填写」时不与历史软删记录冲突。
|
||||
-- 评论同步软删除(is_deleted=1)。
|
||||
-- 3. 同一天允许先存草稿(status=0)后提交(status=1)。
|
||||
-- 4. 不显式指定 COLLATE,沿用数据库默认排序规则,避免与其他表
|
||||
-- JOIN 字符串时出现 Illegal mix of collations。
|
||||
-- =============================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ---------------- 1. 工作日报主表 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_daily_report` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '作者uid(yz_system_tenant_user.uid)',
|
||||
`user_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '作者姓名快照',
|
||||
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '作者部门ID(yz_backend_organization.id)',
|
||||
`org_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '部门名称快照',
|
||||
`report_date` DATE NOT NULL COMMENT '日报日期',
|
||||
`today_done` TEXT COMMENT '今日完成',
|
||||
`tomorrow_plan` TEXT COMMENT '明日计划',
|
||||
`issues` TEXT COMMENT '问题与建议',
|
||||
`attachments` TEXT COMMENT '附件JSON数组:[{"id":1,"url":"","name":"","size":0}]',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '0草稿 1已提交',
|
||||
`submit_time` DATETIME DEFAULT NULL COMMENT '提交时间',
|
||||
`is_deleted` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '软删除标记:0=未删除;删除时写入记录ID(避免唯一键冲突)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tid_user_date` (`tid`, `user_id`, `report_date`, `is_deleted`),
|
||||
KEY `idx_tid_date` (`tid`, `report_date`),
|
||||
KEY `idx_tid_org_date` (`tid`, `org_id`, `report_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作日报';
|
||||
|
||||
-- ---------------- 2. 日报评论表 ----------------
|
||||
-- reply_to_id 指向同表评论ID,用于两级回复(0 表示直接评论日报)。
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_daily_report_comment` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`report_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '日报ID',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '评论人uid',
|
||||
`user_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '评论人姓名快照',
|
||||
`content` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '评论内容',
|
||||
`reply_to_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '回复的评论ID,0=直接评论',
|
||||
`reply_to_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '被回复人姓名快照',
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记:0=未删除 1=已删除',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_report` (`report_id`, `id`),
|
||||
KEY `idx_tid` (`tid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作日报评论';
|
||||
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS yz_platform_wechat_mini_app (
|
||||
name varchar(128) NOT NULL DEFAULT '' COMMENT '应用名称',
|
||||
app_id varchar(64) NOT NULL COMMENT '小程序 AppID',
|
||||
app_secret varchar(512) NOT NULL DEFAULT '' COMMENT 'AppSecret(AES-256-GCM 加密存储)',
|
||||
qrcode_url varchar(512) NOT NULL DEFAULT '' COMMENT '小程序码图片地址(供官网/H5等其他端调取)',
|
||||
enabled tinyint NOT NULL DEFAULT 0 COMMENT '1=启用 0=停用',
|
||||
auto_bind tinyint NOT NULL DEFAULT 1 COMMENT '1=手机号命中身份后自动绑定小程序',
|
||||
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
@@ -32,6 +33,6 @@ WHERE c.app_id <> ''
|
||||
);
|
||||
|
||||
-- 结果校验
|
||||
SELECT id, name, app_id, enabled, auto_bind, remark
|
||||
SELECT id, name, app_id, enabled, auto_bind, remark, qrcode_url
|
||||
FROM yz_platform_wechat_mini_app
|
||||
WHERE delete_time IS NULL;
|
||||
@@ -0,0 +1,64 @@
|
||||
-- ============================================================
|
||||
-- OA 工作日报 —— 菜单配置(yz_system_menu)
|
||||
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
|
||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_dailyreport_menus.sql
|
||||
--
|
||||
-- 挂载位置:OA → 个人办公(path = '/apps/oa/personal')
|
||||
-- 页面:我的日报 + 团队日报(同一页面内 Tab,团队 Tab 由接口权限决定是否显示)
|
||||
-- 说明:platforms 显式声明为 Web + APP(JSON:["web","app"]),小程序端
|
||||
-- 通过 utils/nav.js 的原生规则跳转到 pages/oa/dailyreport/index。
|
||||
--
|
||||
-- 注意:租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
|
||||
-- rights 过滤。只插菜单不授权,会导致「个人办公」目录可见但
|
||||
-- 「工作日报」被过滤掉。因此本脚本同时把菜单 ID 追加到角色 rights。
|
||||
-- ============================================================
|
||||
|
||||
SET @personal_id := (
|
||||
SELECT id FROM yz_system_menu
|
||||
WHERE path = '/apps/oa/personal' AND delete_time IS NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
||||
SELECT @personal_id, '工作日报', '/apps/oa/dailyreport', '/apps/oa/dailyreport/index.vue',
|
||||
'Notebook', 3, 1, 1, '[2]', '["web","app"]', 2, 'system',
|
||||
'按日填写今日完成/明日计划/问题建议,支持附件与评论;主管可查看团队汇总'
|
||||
FROM DUAL
|
||||
WHERE @personal_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/dailyreport' AND delete_time IS NULL) x);
|
||||
|
||||
-- 已有菜单的组件路径统一为带前导斜杠(与个人办公下其他菜单一致)
|
||||
UPDATE yz_system_menu
|
||||
SET component_path = '/apps/oa/dailyreport/index.vue'
|
||||
WHERE path = '/apps/oa/dailyreport'
|
||||
AND delete_time IS NULL
|
||||
AND component_path = 'apps/oa/dailyreport/index.vue';
|
||||
|
||||
SET @report_menu_id := (
|
||||
SELECT id FROM yz_system_menu
|
||||
WHERE path = '/apps/oa/dailyreport' AND delete_time IS NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
|
||||
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
|
||||
UPDATE yz_system_admin_role
|
||||
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @report_menu_id) AS CHAR)
|
||||
WHERE cid = 2
|
||||
AND rights IS NOT NULL
|
||||
AND TRIM(rights) <> ''
|
||||
AND JSON_VALID(rights)
|
||||
AND @report_menu_id IS NOT NULL
|
||||
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@report_menu_id AS JSON));
|
||||
|
||||
-- ---------- 结果校验 ----------
|
||||
SELECT id, pid, title, path, component_path, platforms, type, sort
|
||||
FROM yz_system_menu
|
||||
WHERE delete_time IS NULL AND path = '/apps/oa/dailyreport';
|
||||
|
||||
SELECT id, name, tenant_id,
|
||||
JSON_CONTAINS(CAST(rights AS JSON), CAST(@report_menu_id AS JSON)) AS has_dailyreport
|
||||
FROM yz_system_admin_role
|
||||
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
|
||||
ORDER BY tenant_id, id;
|
||||
@@ -192,6 +192,9 @@ func Init(_ string) {
|
||||
new(OaSchedule),
|
||||
new(OaHoliday),
|
||||
new(OaNotice),
|
||||
// 工作日报(docs/sql/create_oa_dailyreport.sql)
|
||||
new(OaDailyReport),
|
||||
new(OaDailyReportComment),
|
||||
new(OaDocCategory),
|
||||
new(OaDoc),
|
||||
new(OaDocLink),
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// OaDailyReport OA工作日报 yz_backend_oa_daily_report
|
||||
//
|
||||
// 一人一天一条:user_id 存 yz_system_tenant_user.uid(不是 JWT 的 identity_id),
|
||||
// 由 (tid, user_id, report_date, is_deleted) 唯一键保证。
|
||||
// 草稿 status=0,提交后 status=1;删除为软删除。
|
||||
//
|
||||
// IsDeleted 同时承担两个职责:0=未删除;删除时写入记录 ID 作为删除批次标记,
|
||||
// 这样唯一键在「删掉后同一天重新填写」时不会与历史软删记录冲突。
|
||||
type OaDailyReport struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
UserID uint64 `orm:"column(user_id)" json:"user_id"`
|
||||
UserName string `orm:"column(user_name);size(50);default()" json:"user_name"`
|
||||
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
|
||||
OrgName string `orm:"column(org_name);size(100);default()" json:"org_name"`
|
||||
ReportDate string `orm:"column(report_date);type(date)" json:"report_date"`
|
||||
TodayDone string `orm:"column(today_done);type(text);null" json:"today_done"`
|
||||
TomorrowPlan string `orm:"column(tomorrow_plan);type(text);null" json:"tomorrow_plan"`
|
||||
Issues string `orm:"column(issues);type(text);null" json:"issues"`
|
||||
// Attachments 附件JSON数组:[{"id":1,"url":"","name":"","size":0}]
|
||||
Attachments string `orm:"column(attachments);type(text);null" json:"attachments"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 0草稿 1已提交
|
||||
SubmitTime *time.Time `orm:"column(submit_time);type(datetime);null" json:"submit_time"`
|
||||
IsDeleted uint64 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *OaDailyReport) TableName() string {
|
||||
return "yz_backend_oa_daily_report"
|
||||
}
|
||||
|
||||
// OaDailyReportComment OA工作日报评论 yz_backend_oa_daily_report_comment
|
||||
//
|
||||
// ReplyToID 指向同表评论ID,实现两级回复;0 表示直接评论日报。
|
||||
// 删除为软删除(is_deleted=1)。
|
||||
type OaDailyReportComment struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
ReportID uint64 `orm:"column(report_id)" json:"report_id"`
|
||||
UserID uint64 `orm:"column(user_id)" json:"user_id"`
|
||||
UserName string `orm:"column(user_name);size(50);default()" json:"user_name"`
|
||||
Content string `orm:"column(content);size(1000)" json:"content"`
|
||||
ReplyToID uint64 `orm:"column(reply_to_id);default(0)" json:"reply_to_id"`
|
||||
ReplyToName string `orm:"column(reply_to_name);size(50);default()" json:"reply_to_name"`
|
||||
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *OaDailyReportComment) TableName() string {
|
||||
return "yz_backend_oa_daily_report_comment"
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type TenantCrmBusiness struct {
|
||||
ContactWechat string `orm:"column(contact_wechat);size(64)" json:"contact_wechat"`
|
||||
ContactQQ string `orm:"column(contact_qq);size(20)" json:"contact_qq"`
|
||||
Address string `orm:"column(address);size(255)" json:"address"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化项目/3已关闭
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化项目/3已废弃
|
||||
Locked int8 `orm:"column(locked);default(0)" json:"locked"` // 转化项目后锁定
|
||||
ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 转化后的项目ID
|
||||
ConvertTime *time.Time `orm:"column(convert_time);type(datetime);null" json:"convert_time"`
|
||||
|
||||
@@ -30,7 +30,8 @@ type WechatMiniApp struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Name string `orm:"column(name);size(128);default('')" json:"name"`
|
||||
AppID string `orm:"column(app_id);size(64)" json:"app_id"`
|
||||
AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显
|
||||
AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显
|
||||
QrcodeURL string `orm:"column(qrcode_url);size(512);default('')" json:"qrcode_url"` // 小程序码图片地址,供官网 / H5 等其他端调取
|
||||
Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
|
||||
AutoBind int8 `orm:"column(auto_bind);default(1)" json:"auto_bind"` // 1:手机号命中身份后自动绑定
|
||||
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||
|
||||
@@ -108,6 +108,18 @@ func Register() {
|
||||
beego.Router("/api/oa/schedule/upload", &controllers.AppOaController{}, "post:ScheduleUploadImage")
|
||||
beego.Router("/api/oa/schedule/images/:id", &controllers.AppOaController{}, "post:ScheduleImages")
|
||||
|
||||
// OA 工作日报(与 /backend/oa/dailyreport/* 同一套数据;小程序「工作日报」页面使用)
|
||||
beego.Router("/api/oa/dailyreport/profile", &controllers.AppOaController{}, "get:DailyReportProfile")
|
||||
beego.Router("/api/oa/dailyreport/mine/list", &controllers.AppOaController{}, "get:DailyReportMineList")
|
||||
beego.Router("/api/oa/dailyreport/save", &controllers.AppOaController{}, "post:DailyReportSave")
|
||||
beego.Router("/api/oa/dailyreport/detail/:id", &controllers.AppOaController{}, "get:DailyReportDetail")
|
||||
beego.Router("/api/oa/dailyreport/delete/:id", &controllers.AppOaController{}, "delete:DailyReportDelete")
|
||||
beego.Router("/api/oa/dailyreport/team/status", &controllers.AppOaController{}, "get:DailyReportTeamStatus")
|
||||
beego.Router("/api/oa/dailyreport/team/orgs", &controllers.AppOaController{}, "get:DailyReportTeamOrgs")
|
||||
beego.Router("/api/oa/dailyreport/comment", &controllers.AppOaController{}, "post:DailyReportComment")
|
||||
beego.Router("/api/oa/dailyreport/comment/:id", &controllers.AppOaController{}, "delete:DailyReportCommentDelete")
|
||||
beego.Router("/api/oa/dailyreport/upload", &controllers.AppOaController{}, "post:DailyReportUpload")
|
||||
|
||||
// OA 报销管理(与 /backend/reimbursements* 同一套数据;小程序「报销管理」页面使用)
|
||||
beego.Router("/api/oa/expense/list", &controllers.AppOaController{}, "get:ExpenseList")
|
||||
beego.Router("/api/oa/expense/types", &controllers.AppOaController{}, "get:ExpenseTypes")
|
||||
|
||||
@@ -349,6 +349,17 @@ func RegisterAuthRoutes() {
|
||||
// 中国法定节假日/调休日(日历控件休/班标记,数据全局共享、按年缓存)
|
||||
beego.Router("/backend/oa/holiday", &controllers.BackendOaScheduleController{}, "get:Holidays")
|
||||
|
||||
// OA工作日报(本人 / 部门主管 / 管理员三级可见,支持评论)
|
||||
beego.Router("/backend/oa/dailyreport/profile", &controllers.BackendOaDailyReportController{}, "get:Profile")
|
||||
beego.Router("/backend/oa/dailyreport/mine/list", &controllers.BackendOaDailyReportController{}, "get:MineList")
|
||||
beego.Router("/backend/oa/dailyreport/save", &controllers.BackendOaDailyReportController{}, "post:Save")
|
||||
beego.Router("/backend/oa/dailyreport/detail/:id", &controllers.BackendOaDailyReportController{}, "get:Detail")
|
||||
beego.Router("/backend/oa/dailyreport/delete/:id", &controllers.BackendOaDailyReportController{}, "delete:Delete")
|
||||
beego.Router("/backend/oa/dailyreport/team/status", &controllers.BackendOaDailyReportController{}, "get:TeamStatus")
|
||||
beego.Router("/backend/oa/dailyreport/team/orgs", &controllers.BackendOaDailyReportController{}, "get:TeamOrgs")
|
||||
beego.Router("/backend/oa/dailyreport/comment", &controllers.BackendOaDailyReportController{}, "post:Comment")
|
||||
beego.Router("/backend/oa/dailyreport/comment/:id", &controllers.BackendOaDailyReportController{}, "delete:DeleteComment")
|
||||
|
||||
// OA通知公告
|
||||
beego.Router("/backend/oa/notice/list", &controllers.BackendOaNoticeController{}, "get:List")
|
||||
beego.Router("/backend/oa/notice/portal", &controllers.BackendOaNoticeController{}, "get:Portal")
|
||||
@@ -463,6 +474,8 @@ func registerOrganizationRoutes(module string) {
|
||||
beego.Router("/backend/crm/business/:id/convert", &controllers.BackendCrmBusinessController{}, "post:Convert")
|
||||
// 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单
|
||||
beego.Router("/backend/crm/business/:id/stage", &controllers.BackendCrmBusinessController{}, "post:ChangeStage")
|
||||
// 商机状态流转:1跟进中 / 3已废弃(「已转化项目」须走「转项目」流程)
|
||||
beego.Router("/backend/crm/business/:id/status", &controllers.BackendCrmBusinessController{}, "post:ChangeStatus")
|
||||
|
||||
// CRM项目管理
|
||||
beego.Router("/backend/crm/project/list", &controllers.BackendCrmProjectController{}, "get:List")
|
||||
|
||||
@@ -20,6 +20,9 @@ func Register() {
|
||||
// 官网页头导航菜单(平台「官网管理 - 导航菜单」配置,仅返回启用且显示的项)
|
||||
beego.Router("/site/nav", &controllers.IndexWebsiteController{}, "get:Nav")
|
||||
|
||||
// 小程序公开信息(平台「小程序配置」维护,供官网 / H5 等其他端调取二维码)
|
||||
beego.Router("/site/miniapp", &controllers.IndexMiniAppController{}, "get:List")
|
||||
|
||||
// 官网端平台更新公告(公开读取,与平台端 /platform/upgrade 各自独立)
|
||||
beego.Router("/site/upgrade/list", &controllers.IndexUpgradeNoticeController{}, "get:List")
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package dailyreport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/services"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// 日报补写窗口:含今天在内的最近 7 天。
|
||||
const BackfillDays = 7
|
||||
|
||||
// 业务错误:控制器按 Code 决定 HTTP 状态码与提示。
|
||||
type Error struct {
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Msg }
|
||||
|
||||
func errBad(msg string) error { return &Error{Code: 400, Msg: msg} }
|
||||
func errForbidden(msg string) error { return &Error{Code: 403, Msg: msg} }
|
||||
func errNotFound(msg string) error { return &Error{Code: 404, Msg: msg} }
|
||||
|
||||
// StatusOf 取业务错误的 HTTP 状态码,非业务错误回退 fallback。
|
||||
func StatusOf(err error, fallback int) int {
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e.Code
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// UserContext 当前操作人在日报模块内的身份与可见范围。
|
||||
//
|
||||
// UID 是 yz_system_tenant_user.uid(老 uid),不是 JWT 的 identity_id。
|
||||
// TeamOrgIDs 是「当前用户作为负责人的部门」及其全部下级部门;
|
||||
// 为空且非管理员时,只能查看/操作自己的日报。
|
||||
type UserContext struct {
|
||||
Tid uint64
|
||||
UID uint64
|
||||
Name string
|
||||
OrgID uint64
|
||||
OrgName string
|
||||
IsAdmin bool
|
||||
TeamOrgIDs []uint64
|
||||
CanViewTeam bool
|
||||
}
|
||||
|
||||
// InTeamOrg 判断部门是否落在当前用户的团队范围内。
|
||||
func (u *UserContext) InTeamOrg(orgID uint64) bool {
|
||||
for _, id := range u.TeamOrgIDs {
|
||||
if id == orgID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Today 本地日期 "YYYY-MM-DD"。
|
||||
func Today() string {
|
||||
return time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// WindowStart 补写窗口起点(今天往前 BackfillDays-1 天)。
|
||||
func WindowStart() string {
|
||||
return time.Now().AddDate(0, 0, -(BackfillDays - 1)).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// InWindow 判断日期是否在补写窗口内。
|
||||
func InWindow(date string) bool {
|
||||
return date >= WindowStart() && date <= Today()
|
||||
}
|
||||
|
||||
// ParseDate 校验 "YYYY-MM-DD" 并返回规范化的字符串。
|
||||
func ParseDate(date string) (string, error) {
|
||||
date = strings.TrimSpace(date)
|
||||
if date == "" {
|
||||
return Today(), nil
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||
if err != nil {
|
||||
return "", errBad("日报日期格式应为 YYYY-MM-DD")
|
||||
}
|
||||
return t.Format("2006-01-02"), nil
|
||||
}
|
||||
|
||||
// dateOnly 把驱动返回的 DATE 值统一成 "YYYY-MM-DD"。
|
||||
// go-sql-driver 在 parseTime=true 时把 DATE 读成 time.Time,
|
||||
// Beego ORM 赋给 string 字段后会得到 "2026-10-21 00:00:00 +0800 CST",
|
||||
// 直接参与 InWindow 的字符串比较会误判为“不在窗口内”。
|
||||
func dateOnly(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 10 {
|
||||
return s[:10]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// BuildContext 组装当前用户的日报身份与团队范围。
|
||||
// identityUserID 为 JWT 中的 UserID,platformAdmin 表示平台端管理员。
|
||||
func BuildContext(tid uint64, identityUserID uint64, fallbackName string, platformAdmin bool) (*UserContext, error) {
|
||||
if models.Orm == nil || tid == 0 {
|
||||
return nil, errBad("缺少租户信息")
|
||||
}
|
||||
uid := services.ResolveTenantUID(tid, identityUserID)
|
||||
if uid == 0 {
|
||||
return nil, errBad("无法识别当前用户")
|
||||
}
|
||||
|
||||
u := &UserContext{Tid: tid, UID: uid, Name: strings.TrimSpace(fallbackName), IsAdmin: platformAdmin}
|
||||
|
||||
var tu models.SystemTenantUser
|
||||
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("uid", uid).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&tu)
|
||||
if err == nil {
|
||||
u.OrgID = tu.OrgID
|
||||
if n := strings.TrimSpace(ptrString(tu.Name)); n != "" {
|
||||
u.Name = n
|
||||
} else if a := strings.TrimSpace(ptrString(tu.Account)); a != "" {
|
||||
u.Name = a
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
u.IsAdmin = tenantAdmin(tid, &tu)
|
||||
}
|
||||
} else if err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
if u.Name == "" {
|
||||
u.Name = fmt.Sprintf("用户%d", uid)
|
||||
}
|
||||
|
||||
orgs, err := loadOrgs(tid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orgByID := make(map[uint64]*models.BackendOrganization, len(orgs))
|
||||
for i := range orgs {
|
||||
orgByID[orgs[i].ID] = &orgs[i]
|
||||
}
|
||||
if o := orgByID[u.OrgID]; o != nil {
|
||||
u.OrgName = o.OrgName
|
||||
}
|
||||
|
||||
if !u.IsAdmin {
|
||||
leaderKeys := leaderKeys(tid, &tu, identityUserID, uid)
|
||||
roots := make([]uint64, 0, 2)
|
||||
for i := range orgs {
|
||||
if orgs[i].LeaderID != nil && containsID(leaderKeys, *orgs[i].LeaderID) {
|
||||
roots = append(roots, orgs[i].ID)
|
||||
}
|
||||
}
|
||||
u.TeamOrgIDs = collectSubtree(roots, orgs)
|
||||
}
|
||||
u.CanViewTeam = u.IsAdmin || len(u.TeamOrgIDs) > 0
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// tenantAdmin 与 controllers.isTenantAdmin 判定口径一致:
|
||||
// 未分配角色 / 租户管理员角色 / 角色未配置权限,均视为全权限。
|
||||
//
|
||||
// 角色查询额外兼容全局预置角色:「租户管理员」「租户用户」在
|
||||
// yz_system_admin_role 中是 cid=2 且 tenant_id=0 的全局记录,
|
||||
// 只有租户自建角色才带自己的 tenant_id,因此这里同时接受 0 与本租户。
|
||||
func tenantAdmin(tid uint64, tu *models.SystemTenantUser) bool {
|
||||
if tu.GroupID == 0 {
|
||||
return true
|
||||
}
|
||||
cond := orm.NewCondition().
|
||||
And("id", tu.GroupID).
|
||||
And("cid", 2).
|
||||
AndCond(orm.NewCondition().Or("tenant_id", 0).Or("tenant_id", tid))
|
||||
var role models.AdminRole
|
||||
if err := models.Orm.QueryTable(new(models.AdminRole)).SetCond(cond).One(&role); err != nil {
|
||||
return false
|
||||
}
|
||||
if role.Name == "租户管理员" {
|
||||
return true
|
||||
}
|
||||
rights := ""
|
||||
if role.Rights != nil {
|
||||
rights = strings.TrimSpace(*role.Rights)
|
||||
}
|
||||
return rights == "" || rights == "[]" || rights == "null"
|
||||
}
|
||||
|
||||
// leaderKeys 负责人在库里可能存员工ID,也可能存租户用户 uid / 认证中心 uid,
|
||||
// 这里把三种可能都收进来,避免因历史数据差异漏判主管身份。
|
||||
func leaderKeys(tid uint64, tu *models.SystemTenantUser, identityUserID, uid uint64) []uint64 {
|
||||
keys := []uint64{uid}
|
||||
if identityUserID > 0 && identityUserID != uid {
|
||||
keys = append(keys, identityUserID)
|
||||
}
|
||||
account := strings.TrimSpace(ptrString(tu.Account))
|
||||
if account == "" {
|
||||
return keys
|
||||
}
|
||||
var emps []models.BackendEmployee
|
||||
if _, err := models.Orm.QueryTable(new(models.BackendEmployee)).
|
||||
Filter("tid", tid).
|
||||
Filter("account", account).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&emps); err != nil {
|
||||
return keys
|
||||
}
|
||||
for _, e := range emps {
|
||||
keys = append(keys, uint64(e.ID))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// loadOrgs 读取租户内未删除的部门。
|
||||
func loadOrgs(tid uint64) ([]models.BackendOrganization, error) {
|
||||
var orgs []models.BackendOrganization
|
||||
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("sort", "id").
|
||||
All(&orgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
// collectSubtree 返回 roots 及其全部下级部门 ID。
|
||||
func collectSubtree(roots []uint64, orgs []models.BackendOrganization) []uint64 {
|
||||
if len(roots) == 0 {
|
||||
return nil
|
||||
}
|
||||
children := make(map[uint64][]uint64, len(orgs))
|
||||
for _, o := range orgs {
|
||||
if o.ParentID > 0 {
|
||||
children[o.ParentID] = append(children[o.ParentID], o.ID)
|
||||
}
|
||||
}
|
||||
seen := make(map[uint64]bool, len(roots))
|
||||
queue := make([]uint64, 0, len(roots))
|
||||
for _, id := range roots {
|
||||
if id > 0 && !seen[id] {
|
||||
seen[id] = true
|
||||
queue = append(queue, id)
|
||||
}
|
||||
}
|
||||
out := make([]uint64, 0, len(queue))
|
||||
for len(queue) > 0 {
|
||||
id := queue[0]
|
||||
queue = queue[1:]
|
||||
out = append(out, id)
|
||||
for _, child := range children[id] {
|
||||
if !seen[child] {
|
||||
seen[child] = true
|
||||
queue = append(queue, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsID(ids []uint64, id uint64) bool {
|
||||
for _, v := range ids {
|
||||
if v == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ptrString(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package dailyreport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// Attachment 日报附件,与 PC / 小程序上传接口返回的文件信息对齐。
|
||||
type Attachment struct {
|
||||
ID uint64 `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ReportItem 日报列表/详情中的主体。
|
||||
type ReportItem struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
OrgID uint64 `json:"org_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
ReportDate string `json:"report_date"`
|
||||
TodayDone string `json:"today_done"`
|
||||
TomorrowPlan string `json:"tomorrow_plan"`
|
||||
Issues string `json:"issues"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
Status int8 `json:"status"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
}
|
||||
|
||||
// Comment 日报评论(两级回复)。
|
||||
type Comment struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
Content string `json:"content"`
|
||||
ReplyToID uint64 `json:"reply_to_id"`
|
||||
ReplyToName string `json:"reply_to_name"`
|
||||
CreateTime string `json:"create_time"`
|
||||
IsMine bool `json:"is_mine"`
|
||||
CanDelete bool `json:"can_delete"`
|
||||
}
|
||||
|
||||
// Detail 日报详情:主体 + 评论 + 当前用户可执行的操作。
|
||||
type Detail struct {
|
||||
Report ReportItem `json:"report"`
|
||||
Comments []Comment `json:"comments"`
|
||||
CanEdit bool `json:"can_edit"`
|
||||
CanDelete bool `json:"can_delete"`
|
||||
CanComment bool `json:"can_comment"`
|
||||
}
|
||||
|
||||
// SaveInput 保存日报入参;Submit=true 表示提交,否则保存草稿。
|
||||
type SaveInput struct {
|
||||
Date string `json:"date"`
|
||||
TodayDone string `json:"today_done"`
|
||||
TomorrowPlan string `json:"tomorrow_plan"`
|
||||
Issues string `json:"issues"`
|
||||
Attachments string `json:"attachments"`
|
||||
Submit bool `json:"submit"`
|
||||
}
|
||||
|
||||
// MineQuery 我的日报列表查询。
|
||||
type MineQuery struct {
|
||||
StartDate string
|
||||
EndDate string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// MineSummary 我的日报页头统计。
|
||||
type MineSummary struct {
|
||||
MonthTotal int `json:"month_total"`
|
||||
MonthSubmitted int `json:"month_submitted"`
|
||||
TodayStatus int8 `json:"today_status"` // -1=今天未填写
|
||||
TodayReportID uint64 `json:"today_report_id"`
|
||||
WindowStart string `json:"window_start"`
|
||||
WindowEnd string `json:"window_end"`
|
||||
}
|
||||
|
||||
// MineResult 我的日报列表结果。
|
||||
type MineResult struct {
|
||||
List []ReportItem `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Summary MineSummary `json:"summary"`
|
||||
}
|
||||
|
||||
// Save 新建或更新一条日报(同一人同一天唯一)。
|
||||
func Save(u *UserContext, in SaveInput) (*ReportItem, error) {
|
||||
date, err := ParseDate(in.Date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !InWindow(date) {
|
||||
return nil, errBad(fmt.Sprintf("只能补写最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today()))
|
||||
}
|
||||
|
||||
in.TodayDone = strings.TrimSpace(in.TodayDone)
|
||||
in.TomorrowPlan = strings.TrimSpace(in.TomorrowPlan)
|
||||
in.Issues = strings.TrimSpace(in.Issues)
|
||||
for name, text := range map[string]string{
|
||||
"今日完成": in.TodayDone,
|
||||
"明日计划": in.TomorrowPlan,
|
||||
"问题与建议": in.Issues,
|
||||
} {
|
||||
if utf8.RuneCountInString(text) > 5000 {
|
||||
return nil, errBad(name + "不能超过5000字")
|
||||
}
|
||||
}
|
||||
if in.TodayDone == "" && in.TomorrowPlan == "" && in.Issues == "" {
|
||||
return nil, errBad("请至少填写今日完成、明日计划、问题与建议中的一项")
|
||||
}
|
||||
attachmentJSON, _, err := normalizeAttachments(in.Attachments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var existing models.OaDailyReport
|
||||
findErr := models.Orm.QueryTable(new(models.OaDailyReport)).
|
||||
Filter("tid", u.Tid).
|
||||
Filter("user_id", u.UID).
|
||||
Filter("report_date", date).
|
||||
Filter("is_deleted", 0).
|
||||
One(&existing)
|
||||
|
||||
status := int8(0)
|
||||
if in.Submit {
|
||||
status = 1
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
if findErr == orm.ErrNoRows {
|
||||
report := models.OaDailyReport{
|
||||
Tid: u.Tid,
|
||||
UserID: u.UID,
|
||||
UserName: u.Name,
|
||||
OrgID: u.OrgID,
|
||||
OrgName: u.OrgName,
|
||||
ReportDate: date,
|
||||
TodayDone: in.TodayDone,
|
||||
TomorrowPlan: in.TomorrowPlan,
|
||||
Issues: in.Issues,
|
||||
Attachments: attachmentJSON,
|
||||
Status: status,
|
||||
}
|
||||
if status == 1 {
|
||||
report.SubmitTime = &now
|
||||
}
|
||||
if _, err := models.Orm.Insert(&report); err != nil {
|
||||
return nil, errBad("保存失败,该日期可能已存在日报,请刷新后重试")
|
||||
}
|
||||
return buildItem(&report, 0), nil
|
||||
}
|
||||
if findErr != nil {
|
||||
return nil, findErr
|
||||
}
|
||||
|
||||
// 已提交的日报在窗口内允许继续修改,状态保持已提交。
|
||||
if existing.Status == 1 {
|
||||
status = 1
|
||||
}
|
||||
if status == 1 && existing.SubmitTime == nil {
|
||||
existing.SubmitTime = &now
|
||||
}
|
||||
existing.TodayDone = in.TodayDone
|
||||
existing.TomorrowPlan = in.TomorrowPlan
|
||||
existing.Issues = in.Issues
|
||||
existing.Attachments = attachmentJSON
|
||||
existing.Status = status
|
||||
existing.UserName = u.Name
|
||||
existing.OrgID = u.OrgID
|
||||
existing.OrgName = u.OrgName
|
||||
|
||||
if _, err := models.Orm.Update(&existing, "today_done", "tomorrow_plan", "issues",
|
||||
"attachments", "status", "submit_time", "user_name", "org_id", "org_name"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := commentCount(u.Tid, existing.ID)
|
||||
return buildItem(&existing, count), nil
|
||||
}
|
||||
|
||||
// ListMine 我的日报分页列表 + 页头统计。
|
||||
func ListMine(u *UserContext, q MineQuery) (*MineResult, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize < 1 || q.PageSize > 100 {
|
||||
q.PageSize = 20
|
||||
}
|
||||
|
||||
base := models.Orm.QueryTable(new(models.OaDailyReport)).
|
||||
Filter("tid", u.Tid).
|
||||
Filter("user_id", u.UID).
|
||||
Filter("is_deleted", 0)
|
||||
|
||||
start, err := optionalDate(q.StartDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end, err := optionalDate(q.EndDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if start != "" {
|
||||
base = base.Filter("report_date__gte", start)
|
||||
}
|
||||
if end != "" {
|
||||
base = base.Filter("report_date__lte", end)
|
||||
}
|
||||
|
||||
total, _ := base.Count()
|
||||
var rows []models.OaDailyReport
|
||||
if _, err := base.OrderBy("-report_date", "-id").
|
||||
Limit(q.PageSize).Offset((q.Page - 1) * q.PageSize).
|
||||
All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]uint64, 0, len(rows))
|
||||
for i := range rows {
|
||||
ids = append(ids, rows[i].ID)
|
||||
}
|
||||
counts := commentCounts(u.Tid, ids)
|
||||
|
||||
list := make([]ReportItem, 0, len(rows))
|
||||
for i := range rows {
|
||||
list = append(list, *buildItem(&rows[i], counts[rows[i].ID]))
|
||||
}
|
||||
|
||||
summary, err := mineSummary(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MineResult{List: list, Total: total, Summary: *summary}, nil
|
||||
}
|
||||
|
||||
// mineSummary 本月提交统计与今天的状态。
|
||||
func mineSummary(u *UserContext) (*MineSummary, error) {
|
||||
now := time.Now()
|
||||
monthStart := now.Format("2006-01") + "-01"
|
||||
monthEnd := now.AddDate(0, 1, -1).Format("2006-01-02")
|
||||
|
||||
out := &MineSummary{
|
||||
TodayStatus: -1,
|
||||
WindowStart: WindowStart(),
|
||||
WindowEnd: Today(),
|
||||
}
|
||||
base := models.Orm.QueryTable(new(models.OaDailyReport)).
|
||||
Filter("tid", u.Tid).
|
||||
Filter("user_id", u.UID).
|
||||
Filter("is_deleted", 0)
|
||||
out.MonthTotal = int(countWhere(base, "report_date__gte", monthStart, "report_date__lte", monthEnd))
|
||||
out.MonthSubmitted = int(countWhere(base.Filter("status", 1), "report_date__gte", monthStart, "report_date__lte", monthEnd))
|
||||
|
||||
var today models.OaDailyReport
|
||||
if err := models.Orm.QueryTable(new(models.OaDailyReport)).
|
||||
Filter("tid", u.Tid).Filter("user_id", u.UID).
|
||||
Filter("report_date", Today()).
|
||||
Filter("is_deleted", 0).One(&today); err == nil {
|
||||
out.TodayStatus = today.Status
|
||||
out.TodayReportID = today.ID
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetDetail 读取日报详情;无权限时返回 403。
|
||||
func GetDetail(u *UserContext, id uint64) (*Detail, error) {
|
||||
report, err := loadReport(u.Tid, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canViewReport(u, report) {
|
||||
return nil, errForbidden("无权查看该日报")
|
||||
}
|
||||
|
||||
comments := make([]Comment, 0)
|
||||
if report.Status == 1 {
|
||||
comments, err = loadComments(u, report.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
author := report.UserID == u.UID
|
||||
return &Detail{
|
||||
Report: *buildItem(report, len(comments)),
|
||||
Comments: comments,
|
||||
CanEdit: author && InWindow(report.ReportDate),
|
||||
CanDelete: (author && InWindow(report.ReportDate)) || u.IsAdmin,
|
||||
CanComment: report.Status == 1 && (author || u.IsAdmin || u.InTeamOrg(report.OrgID)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Delete 软删除日报及其评论:作者限补写窗口内,管理员不受限。
|
||||
//
|
||||
// 日报的 is_deleted 写入记录 ID(而非 1),既标记删除,又让唯一键
|
||||
// (tid, user_id, report_date, is_deleted) 在「删除后同一天重新填写」时不冲突。
|
||||
func Delete(u *UserContext, id uint64) error {
|
||||
report, err := loadReport(u.Tid, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
author := report.UserID == u.UID
|
||||
if !author && !u.IsAdmin {
|
||||
return errForbidden("无权删除该日报")
|
||||
}
|
||||
if author && !u.IsAdmin && !InWindow(report.ReportDate) {
|
||||
return errBad(fmt.Sprintf("只能删除最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today()))
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tx, err := models.Orm.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.QueryTable(new(models.OaDailyReportComment)).
|
||||
Filter("tid", u.Tid).Filter("report_id", report.ID).Filter("is_deleted", 0).
|
||||
Update(map[string]interface{}{"is_deleted": 1, "delete_time": now}); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if _, err := tx.QueryTable(new(models.OaDailyReport)).
|
||||
Filter("tid", u.Tid).Filter("id", report.ID).
|
||||
Update(map[string]interface{}{"is_deleted": report.ID, "delete_time": now}); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// AddComment 对已提交日报发表评论或回复;replyToID=0 表示直接评论。
|
||||
func AddComment(u *UserContext, reportID uint64, content string, replyToID uint64) (*Comment, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, errBad("请输入评论内容")
|
||||
}
|
||||
if utf8.RuneCountInString(content) > 1000 {
|
||||
return nil, errBad("评论不能超过1000字")
|
||||
}
|
||||
report, err := loadReport(u.Tid, reportID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canViewReport(u, report) {
|
||||
return nil, errForbidden("无权评论该日报")
|
||||
}
|
||||
if report.Status != 1 {
|
||||
return nil, errBad("日报提交后才能评论")
|
||||
}
|
||||
|
||||
replyName := ""
|
||||
if replyToID > 0 {
|
||||
var parent models.OaDailyReportComment
|
||||
if err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
|
||||
Filter("tid", u.Tid).Filter("id", replyToID).Filter("report_id", reportID).
|
||||
Filter("is_deleted", 0).
|
||||
One(&parent); err != nil {
|
||||
return nil, errBad("回复的评论不存在")
|
||||
}
|
||||
replyName = parent.UserName
|
||||
}
|
||||
|
||||
row := models.OaDailyReportComment{
|
||||
Tid: u.Tid,
|
||||
ReportID: reportID,
|
||||
UserID: u.UID,
|
||||
UserName: u.Name,
|
||||
Content: content,
|
||||
ReplyToID: replyToID,
|
||||
ReplyToName: replyName,
|
||||
}
|
||||
if _, err := models.Orm.Insert(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := buildComment(u, &row)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// DeleteComment 软删除评论:评论作者本人或管理员。
|
||||
func DeleteComment(u *UserContext, commentID uint64) error {
|
||||
var row models.OaDailyReportComment
|
||||
if err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
|
||||
Filter("tid", u.Tid).Filter("id", commentID).Filter("is_deleted", 0).One(&row); err != nil {
|
||||
return errNotFound("评论不存在或已删除")
|
||||
}
|
||||
if row.UserID != u.UID && !u.IsAdmin {
|
||||
return errForbidden("只能删除自己的评论")
|
||||
}
|
||||
_, err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
|
||||
Filter("tid", u.Tid).Filter("id", commentID).Filter("is_deleted", 0).
|
||||
Update(map[string]interface{}{"is_deleted": 1, "delete_time": time.Now()})
|
||||
return err
|
||||
}
|
||||
|
||||
// ========================== 内部工具 ==========================
|
||||
|
||||
func loadReport(tid, id uint64) (*models.OaDailyReport, error) {
|
||||
var report models.OaDailyReport
|
||||
if err := models.Orm.QueryTable(new(models.OaDailyReport)).
|
||||
Filter("tid", tid).Filter("id", id).Filter("is_deleted", 0).One(&report); err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, errNotFound("日报不存在或已删除")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// 统一成 YYYY-MM-DD,保证 InWindow 与前端日期比较正确。
|
||||
report.ReportDate = dateOnly(report.ReportDate)
|
||||
return &report, nil
|
||||
}
|
||||
|
||||
// canViewReport 本人 / 管理员 / 作者所在部门的主管;草稿仅本人与管理员可见。
|
||||
func canViewReport(u *UserContext, r *models.OaDailyReport) bool {
|
||||
if r.UserID == u.UID || u.IsAdmin {
|
||||
return true
|
||||
}
|
||||
if r.Status != 1 {
|
||||
return false
|
||||
}
|
||||
return u.InTeamOrg(r.OrgID)
|
||||
}
|
||||
|
||||
func loadComments(u *UserContext, reportID uint64) ([]Comment, error) {
|
||||
var rows []models.OaDailyReportComment
|
||||
if _, err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
|
||||
Filter("tid", u.Tid).Filter("report_id", reportID).
|
||||
Filter("is_deleted", 0).
|
||||
OrderBy("id").All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Comment, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, buildComment(u, &rows[i]))
|
||||
}
|
||||
if out == nil {
|
||||
out = []Comment{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildComment(u *UserContext, r *models.OaDailyReportComment) Comment {
|
||||
return Comment{
|
||||
ID: r.ID,
|
||||
UserID: r.UserID,
|
||||
UserName: r.UserName,
|
||||
Content: r.Content,
|
||||
ReplyToID: r.ReplyToID,
|
||||
ReplyToName: r.ReplyToName,
|
||||
CreateTime: r.CreateTime.Format("2006-01-02 15:04"),
|
||||
IsMine: r.UserID == u.UID,
|
||||
CanDelete: r.UserID == u.UID || u.IsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
func buildItem(r *models.OaDailyReport, commentCount int) *ReportItem {
|
||||
attachments := make([]Attachment, 0)
|
||||
if strings.TrimSpace(r.Attachments) != "" {
|
||||
_ = json.Unmarshal([]byte(r.Attachments), &attachments)
|
||||
}
|
||||
return &ReportItem{
|
||||
ID: r.ID,
|
||||
UserID: r.UserID,
|
||||
UserName: r.UserName,
|
||||
OrgID: r.OrgID,
|
||||
OrgName: r.OrgName,
|
||||
ReportDate: dateOnly(r.ReportDate),
|
||||
TodayDone: r.TodayDone,
|
||||
TomorrowPlan: r.TomorrowPlan,
|
||||
Issues: r.Issues,
|
||||
Attachments: attachments,
|
||||
Status: r.Status,
|
||||
SubmitTime: formatTime(r.SubmitTime),
|
||||
CreateTime: r.CreateTime.Format("2006-01-02 15:04"),
|
||||
UpdateTime: formatTime(r.UpdateTime),
|
||||
CommentCount: commentCount,
|
||||
}
|
||||
}
|
||||
|
||||
func formatTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// normalizeAttachments 校验并规范化附件 JSON,最多 20 个。
|
||||
func normalizeAttachments(raw string) (string, []Attachment, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "null" {
|
||||
return "[]", []Attachment{}, nil
|
||||
}
|
||||
var items []Attachment
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return "", nil, errBad("附件数据格式错误")
|
||||
}
|
||||
out := make([]Attachment, 0, len(items))
|
||||
for _, it := range items {
|
||||
if strings.TrimSpace(it.URL) == "" {
|
||||
continue
|
||||
}
|
||||
if len(out) >= 20 {
|
||||
return "", nil, errBad("附件最多20个")
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
data, _ := json.Marshal(out)
|
||||
return string(data), out, nil
|
||||
}
|
||||
|
||||
func optionalDate(v string) (string, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "", nil
|
||||
}
|
||||
return ParseDate(v)
|
||||
}
|
||||
|
||||
// countWhere 依次应用列值条件后计数。
|
||||
func countWhere(qs orm.QuerySeter, kv ...string) int64 {
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
qs = qs.Filter(kv[i], kv[i+1])
|
||||
}
|
||||
n, _ := qs.Count()
|
||||
return n
|
||||
}
|
||||
|
||||
func commentCount(tid, reportID uint64) int {
|
||||
counts := commentCounts(tid, []uint64{reportID})
|
||||
return counts[reportID]
|
||||
}
|
||||
|
||||
// commentCounts 批量统计评论数,避免列表逐条查询。
|
||||
func commentCounts(tid uint64, ids []uint64) map[uint64]int {
|
||||
out := make(map[uint64]int, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
holders := make([]string, 0, len(ids))
|
||||
args := make([]interface{}, 0, len(ids)+1)
|
||||
args = append(args, tid)
|
||||
for _, id := range ids {
|
||||
holders = append(holders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
var rows []struct {
|
||||
ReportID uint64 `orm:"column(report_id)"`
|
||||
Cnt int `orm:"column(cnt)"`
|
||||
}
|
||||
sql := "SELECT report_id, COUNT(*) AS cnt FROM yz_backend_oa_daily_report_comment" +
|
||||
" WHERE tid = ? AND report_id IN (" + strings.Join(holders, ",") + ") AND is_deleted = 0 GROUP BY report_id"
|
||||
if _, err := models.Orm.Raw(sql, args...).QueryRows(&rows); err != nil {
|
||||
return out
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[r.ReportID] = r.Cnt
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package dailyreport
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// TeamQuery 团队日报汇总查询。
|
||||
type TeamQuery struct {
|
||||
Date string
|
||||
OrgID uint64
|
||||
Keyword string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// TeamStats 团队提交统计。
|
||||
type TeamStats struct {
|
||||
Total int `json:"total"`
|
||||
Submitted int `json:"submitted"`
|
||||
Missing int `json:"missing"`
|
||||
Rate float64 `json:"rate"` // 提交率百分比,保留一位小数
|
||||
}
|
||||
|
||||
// TeamRow 团队汇总中的一名成员。
|
||||
type TeamRow struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
Account string `json:"account"`
|
||||
OrgID uint64 `json:"org_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
Submitted bool `json:"submitted"`
|
||||
ReportID uint64 `json:"report_id"`
|
||||
Status int8 `json:"status"` // -1未填写 0草稿 1已提交
|
||||
SubmitTime string `json:"submit_time"`
|
||||
HasIssue bool `json:"has_issue"`
|
||||
}
|
||||
|
||||
// TeamResult 团队汇总结果。
|
||||
type TeamResult struct {
|
||||
Date string `json:"date"`
|
||||
List []TeamRow `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Stats TeamStats `json:"stats"`
|
||||
}
|
||||
|
||||
// OrgOption 团队筛选用的部门选项(前端按 parent_id 组装树)。
|
||||
type OrgOption struct {
|
||||
ID uint64 `json:"id"`
|
||||
ParentID uint64 `json:"parent_id"`
|
||||
Name string `json:"name"`
|
||||
MemberCount int `json:"member_count"`
|
||||
}
|
||||
|
||||
// TeamStatus 团队日报汇总:按日期返回管辖范围内每位成员的提交情况。
|
||||
// 管理员不选部门时统计全租户(含未分配部门的成员)。
|
||||
func TeamStatus(u *UserContext, q TeamQuery) (*TeamResult, error) {
|
||||
if !u.CanViewTeam {
|
||||
return nil, errForbidden("无权查看团队日报")
|
||||
}
|
||||
date, err := ParseDate(q.Date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if date > Today() {
|
||||
return nil, errBad("不能查看未来日期的团队日报")
|
||||
}
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize < 1 || q.PageSize > 100 {
|
||||
q.PageSize = 20
|
||||
}
|
||||
|
||||
orgs, err := loadOrgs(u.Tid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orgByID := make(map[uint64]*models.BackendOrganization, len(orgs))
|
||||
for i := range orgs {
|
||||
orgByID[orgs[i].ID] = &orgs[i]
|
||||
}
|
||||
|
||||
var scopeIDs []uint64
|
||||
if q.OrgID > 0 {
|
||||
if !u.IsAdmin && !u.InTeamOrg(q.OrgID) {
|
||||
return nil, errForbidden("无权查看该部门")
|
||||
}
|
||||
if _, ok := orgByID[q.OrgID]; !ok {
|
||||
return nil, errBad("部门不存在")
|
||||
}
|
||||
scopeIDs = collectSubtree([]uint64{q.OrgID}, orgs)
|
||||
} else if !u.IsAdmin {
|
||||
scopeIDs = u.TeamOrgIDs
|
||||
}
|
||||
if scopeIDs != nil && len(scopeIDs) == 0 {
|
||||
return nil, errForbidden("你还没有管辖的部门")
|
||||
}
|
||||
|
||||
cond := orm.NewCondition().
|
||||
And("tid", u.Tid).
|
||||
And("delete_time__isnull", true).
|
||||
And("status", 1).
|
||||
AndNot("uid", u.UID)
|
||||
if scopeIDs != nil {
|
||||
cond = cond.And("org_id__in", scopeIDs)
|
||||
}
|
||||
if kw := strings.TrimSpace(q.Keyword); kw != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().
|
||||
Or("name__icontains", kw).
|
||||
Or("account__icontains", kw))
|
||||
}
|
||||
base := models.Orm.QueryTable(new(models.SystemTenantUser)).SetCond(cond)
|
||||
|
||||
total, _ := base.Count()
|
||||
var members []models.SystemTenantUser
|
||||
if _, err := base.OrderBy("org_id", "uid").
|
||||
Limit(q.PageSize).Offset((q.Page - 1) * q.PageSize).
|
||||
All(&members); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 统计口径覆盖全部成员,不受分页影响。
|
||||
var allMembers []models.SystemTenantUser
|
||||
if _, err := base.OrderBy("uid").All(&allMembers, "uid"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reports, err := loadReportsByDate(u.Tid, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := TeamStats{Total: len(allMembers)}
|
||||
for _, m := range allMembers {
|
||||
if r := reports[m.Uid]; r != nil && r.Status == 1 {
|
||||
stats.Submitted++
|
||||
}
|
||||
}
|
||||
stats.Missing = stats.Total - stats.Submitted
|
||||
if stats.Total > 0 {
|
||||
stats.Rate = math.Round(float64(stats.Submitted)*1000/float64(stats.Total)) / 10
|
||||
}
|
||||
|
||||
list := make([]TeamRow, 0, len(members))
|
||||
for i := range members {
|
||||
m := &members[i]
|
||||
row := TeamRow{
|
||||
UserID: m.Uid,
|
||||
UserName: userNameOf(m),
|
||||
Account: ptrString(m.Account),
|
||||
OrgID: m.OrgID,
|
||||
Status: -1,
|
||||
}
|
||||
if o := orgByID[m.OrgID]; o != nil {
|
||||
row.OrgName = o.OrgName
|
||||
}
|
||||
if r := reports[m.Uid]; r != nil {
|
||||
row.ReportID = r.ID
|
||||
row.Status = r.Status
|
||||
row.Submitted = r.Status == 1
|
||||
row.SubmitTime = formatTime(r.SubmitTime)
|
||||
row.HasIssue = strings.TrimSpace(r.Issues) != ""
|
||||
}
|
||||
list = append(list, row)
|
||||
}
|
||||
return &TeamResult{Date: date, List: list, Total: total, Stats: stats}, nil
|
||||
}
|
||||
|
||||
// TeamOrgs 返回团队筛选可选部门;管理员为全租户,主管仅自己管辖的部门子树。
|
||||
func TeamOrgs(u *UserContext) ([]OrgOption, error) {
|
||||
if !u.CanViewTeam {
|
||||
return nil, errForbidden("无权查看团队日报")
|
||||
}
|
||||
orgs, err := loadOrgs(u.Tid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allow := make(map[uint64]bool, len(orgs))
|
||||
if u.IsAdmin {
|
||||
for _, o := range orgs {
|
||||
allow[o.ID] = true
|
||||
}
|
||||
} else {
|
||||
for _, id := range u.TeamOrgIDs {
|
||||
allow[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
var users []models.SystemTenantUser
|
||||
if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", u.Tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("status", 1).
|
||||
All(&users, "org_id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[uint64]int, len(orgs))
|
||||
for _, m := range users {
|
||||
if m.OrgID > 0 {
|
||||
counts[m.OrgID]++
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]OrgOption, 0, len(allow))
|
||||
for i := range orgs {
|
||||
o := &orgs[i]
|
||||
if !allow[o.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, OrgOption{
|
||||
ID: o.ID,
|
||||
ParentID: o.ParentID,
|
||||
Name: o.OrgName,
|
||||
MemberCount: counts[o.ID],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadReportsByDate 一次取回某日全部已提交/草稿日报,按作者 uid 建索引。
|
||||
func loadReportsByDate(tid uint64, date string) (map[uint64]*models.OaDailyReport, error) {
|
||||
var rows []models.OaDailyReport
|
||||
if _, err := models.Orm.QueryTable(new(models.OaDailyReport)).
|
||||
Filter("tid", tid).
|
||||
Filter("report_date", date).
|
||||
Filter("is_deleted", 0).
|
||||
All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uint64]*models.OaDailyReport, len(rows))
|
||||
for i := range rows {
|
||||
out[rows[i].UserID] = &rows[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func userNameOf(m *models.SystemTenantUser) string {
|
||||
if n := strings.TrimSpace(ptrString(m.Name)); n != "" {
|
||||
return n
|
||||
}
|
||||
if a := strings.TrimSpace(ptrString(m.Account)); a != "" {
|
||||
return a
|
||||
}
|
||||
return "未命名成员"
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type Config struct {
|
||||
Name string
|
||||
AppID string
|
||||
AppSecret string // 明文
|
||||
QrcodeURL string // 小程序码图片地址(对外展示)
|
||||
Enabled bool
|
||||
AutoBind bool
|
||||
Remark string
|
||||
@@ -35,12 +36,13 @@ var ErrNotConfigured = errors.New("微信小程序未配置或未启用")
|
||||
// appFromRow 解密行数据为运行配置
|
||||
func appFromRow(row models.WechatMiniApp) (*Config, error) {
|
||||
cfg := &Config{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
AppID: strings.TrimSpace(row.AppID),
|
||||
Enabled: row.Enabled == 1,
|
||||
AutoBind: row.AutoBind != 0,
|
||||
Remark: row.Remark,
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
AppID: strings.TrimSpace(row.AppID),
|
||||
QrcodeURL: strings.TrimSpace(row.QrcodeURL),
|
||||
Enabled: row.Enabled == 1,
|
||||
AutoBind: row.AutoBind != 0,
|
||||
Remark: row.Remark,
|
||||
}
|
||||
if row.AppSecret != "" {
|
||||
plain, derr := wechatmp.DecryptSecret(row.AppSecret)
|
||||
@@ -140,12 +142,44 @@ func ListApps() ([]models.WechatMiniApp, error) {
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// PublicApp 对外公布的小程序信息(供官网 / H5 等其他端展示二维码)
|
||||
type PublicApp struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"app_id"`
|
||||
QrcodeURL string `json:"qrcode_url"`
|
||||
}
|
||||
|
||||
// ListEnabledApps 已启用的小程序(含二维码地址),供其他端公开读取
|
||||
func ListEnabledApps() ([]PublicApp, error) {
|
||||
var rows []models.WechatMiniApp
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("enabled", 1).
|
||||
OrderBy("id").
|
||||
All(&rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]PublicApp, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
list = append(list, PublicApp{
|
||||
ID: r.ID,
|
||||
Name: strings.TrimSpace(r.Name),
|
||||
AppID: strings.TrimSpace(r.AppID),
|
||||
QrcodeURL: strings.TrimSpace(r.QrcodeURL),
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// SaveAppInput 保存应用入参(AppSecret 支持掩码表示不修改)
|
||||
type SaveAppInput struct {
|
||||
ID uint64
|
||||
Name string
|
||||
AppID string
|
||||
AppSecret string // 掩码 / 空串 = 保持不变
|
||||
QrcodeURL string // 空串 = 清空二维码
|
||||
Enabled bool
|
||||
AutoBind bool
|
||||
Remark string
|
||||
@@ -202,6 +236,7 @@ func SaveApp(in SaveAppInput) (uint64, error) {
|
||||
if in.Enabled {
|
||||
row.Enabled = 1
|
||||
}
|
||||
row.QrcodeURL = strings.TrimSpace(in.QrcodeURL)
|
||||
row.AutoBind = 0
|
||||
if in.AutoBind {
|
||||
row.AutoBind = 1
|
||||
@@ -273,6 +308,7 @@ func SaveConfig(in SaveInput) error {
|
||||
Name: row.Name,
|
||||
AppID: in.AppID,
|
||||
AppSecret: in.AppSecret,
|
||||
QrcodeURL: row.QrcodeURL, // 旧端点不改动二维码
|
||||
Enabled: in.Enabled,
|
||||
AutoBind: in.AutoBind,
|
||||
Remark: in.Remark,
|
||||
|
||||
Reference in new issue
Block a user