优化
This commit is contained in:
1 parent
d9a50e23dd
commit
4f1acaa451
25 files changed
+1265
-335
No files matched your search
@@ -450,7 +450,7 @@ func (c *BackendCrmBiddingBidController) ConvertContract() {
|
||||
}
|
||||
|
||||
crmWriteLog(tenantID, 4, bidIDForLog, "convert-contract", "中标转合同:"+contractName, claims)
|
||||
crmWriteLog(tenantID, 1, cid, "create", "由投标「"+bid.Title+"」中标转化生成", claims)
|
||||
crmWriteLog(tenantID, 6, cid, "create", "由投标「"+bid.Title+"」中标转化生成", claims)
|
||||
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"contract_id": contractID})
|
||||
}
|
||||
|
||||
@@ -503,7 +503,7 @@ func (c *BackendCrmBiddingTenderController) AwardToContract() {
|
||||
}
|
||||
|
||||
crmWriteLog(tenantID, 5, tenderID, "award-contract", "定标转合同:"+contractName, claims)
|
||||
crmWriteLog(tenantID, 1, cid, "create", "由招标「"+row.Title+"」定标转化生成", claims)
|
||||
crmWriteLog(tenantID, 6, cid, "create", "由招标「"+row.Title+"」定标转化生成", claims)
|
||||
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"contract_id": contractID})
|
||||
}
|
||||
@@ -376,7 +376,7 @@ func (c *BackendCrmContractController) Create() {
|
||||
}
|
||||
// 参与方填写的签约人(姓名 + 电话)同步写入对应企业的联系人通讯录
|
||||
syncContractPartyContacts(tenantID, row.Parties)
|
||||
crmWriteLog(tenantID, 3, row.ID, "create", "创建合同:"+row.ContractName, claims)
|
||||
crmWriteLog(tenantID, 6, row.ID, "create", "创建合同:"+row.ContractName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
|
||||
}
|
||||
|
||||
@@ -493,7 +493,7 @@ func (c *BackendCrmContractController) Update() {
|
||||
}
|
||||
// 参与方填写的签约人(姓名 + 电话)同步写入对应企业的联系人通讯录
|
||||
syncContractPartyContacts(tenantID, row.Parties)
|
||||
crmWriteLog(tenantID, 3, row.ID, "update", "更新合同:"+row.ContractName, claims)
|
||||
crmWriteLog(tenantID, 6, row.ID, "update", "更新合同:"+row.ContractName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@ func (c *BackendCrmContractController) ChangeStatus() {
|
||||
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 3, row.ID, "status", fmt.Sprintf("合同状态流转:%s → %s", contractStatusName(row.Status), contractStatusName(p.Status)), claims)
|
||||
crmWriteLog(tenantID, 6, row.ID, "status", fmt.Sprintf("合同状态流转:%s → %s", contractStatusName(row.Status), contractStatusName(p.Status)), claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": p.Status})
|
||||
}
|
||||
|
||||
|
||||
@@ -881,7 +881,7 @@ func (c *BackendCrmPaybackController) CreateRecord() {
|
||||
}
|
||||
refreshPlanReceived(tenantID, plan.ID)
|
||||
if contractID != nil {
|
||||
crmWriteLog(tenantID, 3, *contractID, "payback",
|
||||
crmWriteLog(tenantID, 6, *contractID, "payback",
|
||||
fmt.Sprintf("新建回款:%s 元(%s)", formatAmount(row.Amount), row.ReceivedDate), claims)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
|
||||
@@ -447,14 +447,14 @@ func (c *BackendOaScheduleController) Stats() {
|
||||
}
|
||||
|
||||
type oaSchedulePayload struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
ScheduleDate string `json:"schedule_date"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
AllDay int8 `json:"all_day"`
|
||||
Color string `json:"color"`
|
||||
Priority int8 `json:"priority"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
ScheduleDate string `json:"schedule_date"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
AllDay int8 `json:"all_day"`
|
||||
Color string `json:"color"`
|
||||
Priority int8 `json:"priority"`
|
||||
// 提醒绑定:开启后会在提醒工具生成一条提醒任务,并把其 ID 回写到 ReminderID。
|
||||
ReminderEnabled bool `json:"reminder_enabled"`
|
||||
ReminderTime string `json:"reminder_time"`
|
||||
@@ -1157,6 +1157,7 @@ func (c *BackendOaScheduleController) CarrySchedule() {
|
||||
// 目标日期可选,不传则默认顺延到下一天
|
||||
var payload struct {
|
||||
TargetDate string `json:"target_date"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &payload)
|
||||
@@ -1180,7 +1181,56 @@ func (c *BackendOaScheduleController) CarrySchedule() {
|
||||
targetDate = nextOaDate(normalizeOaDate(item.ScheduleDate), 1)
|
||||
}
|
||||
|
||||
mode := strings.TrimSpace(payload.Mode)
|
||||
if mode != "" && mode != "to" && mode != "through" {
|
||||
c.oasJsonErr(400, 400, "延期方式无效")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if mode == "through" {
|
||||
startDate := normalizeOaDate(item.ScheduleDate)
|
||||
if targetDate <= startDate {
|
||||
c.oasJsonErr(400, 400, "延期至日期需晚于当前日程日期")
|
||||
return
|
||||
}
|
||||
// 延期至代表将原日程推至终点日期,并在起始日之后到终点日的每一天创建独立待办。
|
||||
rootID := oaScheduleRootID(&item)
|
||||
created := 0
|
||||
for date := nextOaDate(startDate, 1); date != "" && date <= targetDate; date = nextOaDate(date, 1) {
|
||||
exists := models.Orm.QueryTable(new(models.OaSchedule)).Filter("is_deleted", 0).
|
||||
Filter("tid", claims.TenantId).Filter("user_id", claims.UserID).
|
||||
Filter("source_id", rootID).Filter("schedule_date", date).Exist()
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
next := &models.OaSchedule{
|
||||
Tid: item.Tid, UserID: item.UserID, UserName: item.UserName,
|
||||
Title: item.Title, Content: item.Content, Images: item.Images,
|
||||
ScheduleDate: date, StartTime: item.StartTime, EndTime: item.EndTime,
|
||||
AllDay: item.AllDay, Color: item.Color, Priority: item.Priority,
|
||||
Status: 0, SourceID: rootID, CarryCount: item.CarryCount + 1,
|
||||
IsDeleted: 0, CreateTime: now, UpdateTime: &now,
|
||||
}
|
||||
if _, insertErr := models.Orm.Insert(next); insertErr != nil {
|
||||
c.oasJsonErr(500, 500, "生成延期日程失败")
|
||||
return
|
||||
}
|
||||
created++
|
||||
}
|
||||
item.Status = 1
|
||||
item.FinishTime = &now
|
||||
item.CarryOver = 1
|
||||
item.CarryToDate = targetDate
|
||||
item.UpdateTime = &now
|
||||
if _, updateErr := models.Orm.Update(&item, "Status", "FinishTime", "CarryOver", "CarryToDate", "UpdateTime"); updateErr != nil {
|
||||
c.oasJsonErr(500, 500, "更新原日程失败")
|
||||
return
|
||||
}
|
||||
c.oasOk(map[string]interface{}{"created": created, "schedule_date": targetDate, "mode": "through"})
|
||||
return
|
||||
}
|
||||
|
||||
next, msg := carryOaSchedule(&item, targetDate, claims, now)
|
||||
if msg != "" {
|
||||
c.oasJsonErr(400, 400, msg)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
authSvc "server/services/auth"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
@@ -778,11 +779,11 @@ func (c *BackendOaSchedulingController) ClearEntries() {
|
||||
c.scOK(map[string]interface{}{"cleared": len(entries)})
|
||||
}
|
||||
|
||||
// MyBoard 当前登录用户的排班:按登录账号匹配员工档案,返回区间内每天的排班,
|
||||
// 供日程日历渲染上班/休息背景。
|
||||
// MyBoard 当前登录用户的排班:按登录身份匹配员工档案,返回区间内每天的排班,
|
||||
// 供工作台今日卡片与日程日历渲染上班/休息背景。
|
||||
// GET /backend/oa/scheduling/my?start_date=&end_date=
|
||||
func (c *BackendOaSchedulingController) MyBoard() {
|
||||
tid, _, username, ok := c.scOperator()
|
||||
tid, uid, username, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -804,9 +805,11 @@ func (c *BackendOaSchedulingController) MyBoard() {
|
||||
return
|
||||
}
|
||||
|
||||
// 账号未关联员工档案时返回空列表,前端静默不渲染排班背景
|
||||
var employee models.BackendEmployee
|
||||
if err := c.scBaseEmployeeQuery(tid).Filter("account", username).One(&employee); err != nil {
|
||||
// 解析当前登录者对应的员工档案。OIDC 换码签发的令牌里 username 为空,
|
||||
// 即使有值也是认证中心昵称而非员工工号,因此以「身份+租户」绑定账号为主,
|
||||
// 再用令牌 username 按 账号 → 姓名 兜底。都匹配不到时返回空列表。
|
||||
employee, ok := c.scFindSelfEmployee(tid, uid, username)
|
||||
if !ok {
|
||||
c.scOK(map[string]interface{}{"employee": nil, "list": []map[string]interface{}{}})
|
||||
return
|
||||
}
|
||||
@@ -839,6 +842,59 @@ func (c *BackendOaSchedulingController) MyBoard() {
|
||||
})
|
||||
}
|
||||
|
||||
// scFindSelfEmployee 把登录身份映射到本租户的员工档案。
|
||||
// 优先取认证中心「身份+租户」绑定记录里的账号(与 /auth/userinfo 同源,最可靠),
|
||||
// 其次用令牌 username 按「账号 → 姓名」匹配。
|
||||
func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint64, username string) (models.BackendEmployee, bool) {
|
||||
var employee models.BackendEmployee
|
||||
|
||||
if identityID > 0 {
|
||||
if bind, err := authSvc.GetTenantUser(identityID, tid); err == nil && bind != nil {
|
||||
if v := authBindValue(bind.Account); v != "" {
|
||||
if emp, found := c.scEmployeeByField(tid, "account", v); found {
|
||||
return emp, true
|
||||
}
|
||||
}
|
||||
if v := authBindValue(bind.Name); v != "" {
|
||||
if emp, found := c.scEmployeeByField(tid, "name", v); found {
|
||||
return emp, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
username = strings.TrimSpace(username)
|
||||
if username != "" {
|
||||
for _, field := range []string{"account", "name"} {
|
||||
if emp, found := c.scEmployeeByField(tid, field, username); found {
|
||||
return emp, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return employee, false
|
||||
}
|
||||
|
||||
// scEmployeeByField 在本租户员工档案中按指定字段匹配,取第一条有效记录
|
||||
func (c *BackendOaSchedulingController) scEmployeeByField(tid uint64, field, value string) (models.BackendEmployee, bool) {
|
||||
var rows []models.BackendEmployee
|
||||
if _, err := c.scBaseEmployeeQuery(tid).
|
||||
Filter(field, value).
|
||||
OrderBy("id").
|
||||
Limit(1).
|
||||
All(&rows); err == nil && len(rows) > 0 {
|
||||
return rows[0], true
|
||||
}
|
||||
return models.BackendEmployee{}, false
|
||||
}
|
||||
|
||||
// authBindValue 取认证绑定记录里的可空字符串
|
||||
func authBindValue(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*p)
|
||||
}
|
||||
|
||||
// ChangeList 排班变更记录(最近 100 条),用于追溯临时调配。
|
||||
// GET /backend/oa/scheduling/changes?start_date=&end_date=&employee_id=
|
||||
func (c *BackendOaSchedulingController) ChangeList() {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendWeatherController 天气实时数据代理。
|
||||
//
|
||||
// 数据源:中国天气网 d1.weather.com.cn(国内真实数据)。
|
||||
// 该接口有防盗链,浏览器直连会被 Referer 校验拦掉、也会跨域,
|
||||
// 因此由服务端带 Referer 抓取后统一返回 JSON。
|
||||
type BackendWeatherController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// 常用城市编码(中国天气网 citycode),未命中时按入参 code 直取
|
||||
var weatherCityCodes = map[string]string{
|
||||
"连云港": "101191001",
|
||||
"南京": "101190101",
|
||||
"苏州": "101190401",
|
||||
"无锡": "101190201",
|
||||
"常州": "101191101",
|
||||
"南通": "101190501",
|
||||
"扬州": "101190601",
|
||||
"徐州": "101190801",
|
||||
"盐城": "101190701",
|
||||
"淮安": "101190901",
|
||||
"镇江": "101190301",
|
||||
"泰州": "101191201",
|
||||
"宿迁": "101191301",
|
||||
"北京": "101010100",
|
||||
"上海": "101020100",
|
||||
"广州": "101280101",
|
||||
"深圳": "101280601",
|
||||
"杭州": "101210101",
|
||||
}
|
||||
|
||||
const defaultWeatherCity = "连云港"
|
||||
|
||||
// Now GET /backend/weather/now?city=连云港 或 ?code=101191201
|
||||
func (c *BackendWeatherController) Now() {
|
||||
city := strings.TrimSpace(c.GetString("city"))
|
||||
code := strings.TrimSpace(c.GetString("code"))
|
||||
if code == "" {
|
||||
code = weatherCityCodes[city]
|
||||
}
|
||||
if code == "" {
|
||||
code = weatherCityCodes[defaultWeatherCity]
|
||||
city = defaultWeatherCity
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
referer := "https://www.weather.com.cn/weather1d/" + code + ".shtml"
|
||||
|
||||
// 主数据源:dingzhi(含天气现象 + 气温)。中国天气网已拒绝 http 明文连接,必须走 https。
|
||||
info := weatherFetchJSON(client, "https://d1.weather.com.cn/dingzhi/"+code+".html?_="+ts, referer)
|
||||
if wi, ok := info["weatherinfo"].(map[string]interface{}); ok {
|
||||
info = wi
|
||||
}
|
||||
// 兜底:sk_2d(实况气温,无天气现象)
|
||||
if len(info) == 0 {
|
||||
info = weatherFetchJSON(client, "https://d1.weather.com.cn/sk_2d/"+code+".html?_="+ts, referer)
|
||||
}
|
||||
|
||||
if len(info) == 0 {
|
||||
c.Ctx.Output.SetStatus(http.StatusBadGateway)
|
||||
c.Data["json"] = map[string]interface{}{"code": 502, "msg": "天气数据获取失败,请稍后重试"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
cityName := weatherStr(info, "cityname", "city", "name")
|
||||
if cityName == "" {
|
||||
cityName = city
|
||||
}
|
||||
temp := strings.TrimSuffix(weatherStr(info, "temp", "real"), "℃")
|
||||
text := weatherStr(info, "weather", "weather1")
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"city": cityName,
|
||||
"temp": temp,
|
||||
"text": text,
|
||||
"update": weatherStr(info, "time", "fctime", "date"),
|
||||
"code": code,
|
||||
},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// weatherFetchJSON 带 Referer/UA 抓取中国天气网页面并解析其中的 JSON 对象
|
||||
func weatherFetchJSON(client *http.Client, url, referer string) map[string]interface{} {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Referer", referer)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 返回体形如:var cityDZ101191001 ={"weatherinfo":{...}};var alarmDZ101191001 ={"w":[]}
|
||||
// 从第一个 '{' 起用 json.Decoder 只解出第一个完整对象(正则贪婪匹配会跨越多个对象导致解析失败)
|
||||
idx := bytes.IndexByte(body, '{')
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if json.NewDecoder(bytes.NewReader(body[idx:])).Decode(&out) != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// weatherStr 按候选字段顺序取第一个非空字符串值
|
||||
func weatherStr(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -313,6 +313,10 @@ func shouldSkipLogging(method, url string) bool {
|
||||
if strings.HasPrefix(url, "/api/softwareupgrade/check") {
|
||||
return true
|
||||
}
|
||||
// 工作台天气实况(代理外部数据源,非业务写操作)
|
||||
if strings.HasPrefix(url, "/backend/weather/") {
|
||||
return true
|
||||
}
|
||||
// 移动端(已并入 /api/)读接口跳过(只记录写操作:POST/PUT/DELETE)
|
||||
if isMobileAppPath(url) {
|
||||
return true
|
||||
|
||||
@@ -3,7 +3,7 @@ package models
|
||||
import "time"
|
||||
|
||||
// TenantCrmOperateLog 操作日志表: yz_backend_crm_operate_log
|
||||
// related_type: 1线索 / 2商机 / 3项目
|
||||
// related_type: 1线索 / 2商机 / 3项目 / 4投标 / 5招标 / 6合同
|
||||
type TenantCrmOperateLog struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
|
||||
@@ -159,6 +159,9 @@ func RegisterAuthRoutes() {
|
||||
// 模块接口
|
||||
beego.Router("/backend/modules/getTenantList", &controllers.BackendModulesController{}, "get:GetTenantList")
|
||||
|
||||
// 天气实况(代理中国天气网,供工作台今日卡片使用)
|
||||
beego.Router("/backend/weather/now", &controllers.BackendWeatherController{}, "get:Now")
|
||||
|
||||
// 用户接口
|
||||
beego.Router("/backend/getTenantUsers/:tid", &controllers.BackendAdminUserController{}, "get:GetTenantUsers")
|
||||
beego.Router("/backend/getAllUsers", &controllers.BackendAdminUserController{}, "get:GetAllUsers")
|
||||
|
||||
@@ -189,14 +189,14 @@ func crmContractStatusText(s int8) string {
|
||||
|
||||
// writeOverdueStatusLog 逾期联动自动改状态时写入 CRM 操作日志。
|
||||
// 自动置「异常」与人工流转同一口径:日志里必须写明原因,操作者记为「系统自动」,便于事后追溯。
|
||||
// relatedType=3 与控制器层一致(项目 / 合同的操作日志同属该分类)。
|
||||
func writeOverdueStatusLog(tenantID string, relatedID uint64, content string, now time.Time) {
|
||||
// relatedType: 3=项目 6=合同(合同已独立分配 type=6,与项目解耦避免日志串数据)。
|
||||
func writeOverdueStatusLog(tenantID string, relatedType int8, relatedID uint64, content string, now time.Time) {
|
||||
if models.Orm == nil || relatedID == 0 {
|
||||
return
|
||||
}
|
||||
log := models.TenantCrmOperateLog{
|
||||
TenantID: tenantID,
|
||||
RelatedType: 3,
|
||||
RelatedType: relatedType,
|
||||
RelatedID: relatedID,
|
||||
Action: "status",
|
||||
Content: content,
|
||||
@@ -228,7 +228,7 @@ func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (in
|
||||
}
|
||||
contractUpdated = 1
|
||||
// 自动改异常同样写明原因,否则日志里只看到"变异常"却不知为何
|
||||
writeOverdueStatusLog(tenantID, contractID,
|
||||
writeOverdueStatusLog(tenantID, 6, contractID,
|
||||
"合同状态自动流转:"+crmContractStatusText(contract.Status)+" → 异常;异常原因:回款计划逾期未收齐", now)
|
||||
}
|
||||
// 合同关联项目联动置异常
|
||||
@@ -242,7 +242,7 @@ func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (in
|
||||
Filter("id", proj.ID).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"status": int8(5), "update_time": now}); err == nil {
|
||||
projectUpdated = 1
|
||||
writeOverdueStatusLog(tenantID, proj.ID,
|
||||
writeOverdueStatusLog(tenantID, 3, proj.ID,
|
||||
"项目状态自动流转:"+crmProjectStatusText(proj.Status)+" → 异常;异常原因:关联合同「"+
|
||||
contract.ContractName+"」回款计划逾期未收齐", now)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user