This commit is contained in:
hero920103 committed 2026-11-11 08:53:14 +08:00
1 parent d9a50e23dd
commit 4f1acaa451
25 files changed
+1265 -335

No files matched your search

+1 -1
View File
@@ -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})
}
+1 -1
View File
@@ -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})
}
+3 -3
View File
@@ -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})
}
+1 -1
View File
@@ -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})
+58 -8
View File
@@ -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)
+62 -6
View File
@@ -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() {
+146
View File
@@ -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 ""
}
+4
View File
@@ -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
+1 -1
View File
@@ -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"`
+3
View File
@@ -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")
+5 -5
View File
@@ -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)
}