增加招投标模块

This commit is contained in:
2026-09-24 17:06:46 +08:00
parent bc9e32d62a
commit eafa5e1cf4
51 changed files with 13453 additions and 5 deletions
+846
View File
@@ -0,0 +1,846 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// CRM 招投标管理 - 投标方(Bid)
// =============================================================
//
// 状态机:0草稿 -> 1立项审批中 -> 2投标中 -> 3已开标 -> (4中标 | 5落标) -> (6已转合同 | 7已归档)
// 关联:商机(1)/项目(2)/独立(0) -> 中标 -> 合同/项目
// =============================================================
// BackendCrmBiddingBidController 投标方
type BackendCrmBiddingBidController struct {
beego.Controller
}
// bidPayload 创建/更新请求体
type bidPayload struct {
BidNo string `json:"bid_no"`
Title string `json:"title"`
BidType string `json:"bid_type"`
SourceType int8 `json:"source_type"`
SourceID uint64 `json:"source_id"`
BusinessID uint64 `json:"business_id"`
BusinessName string `json:"business_name"`
ProjectID uint64 `json:"project_id"`
ProjectName string `json:"project_name"`
TenderID uint64 `json:"tender_id"`
TenderNo string `json:"tender_no"`
CustomerID uint64 `json:"customer_id"`
CustomerName string `json:"customer_name"`
PublishOrg string `json:"publish_org"`
BidAmount float64 `json:"bid_amount"`
BidDeposit float64 `json:"bid_deposit"`
PublishDate string `json:"publish_date"`
RegisterEndDate string `json:"register_end_date"`
BidEndDate string `json:"bid_end_date"`
OpenBidDate string `json:"open_bid_date"`
ContactPerson string `json:"contact_person"`
ContactPhone string `json:"contact_phone"`
BidAddress string `json:"bid_address"`
OpenAddress string `json:"open_address"`
OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"`
Description string `json:"description"`
Remark string `json:"remark"`
}
// List GET /backend/crm/bidding/bid/list
func (c *BackendCrmBiddingBidController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
keyword := strings.TrimSpace(c.GetString("keyword"))
status := strings.TrimSpace(c.GetString("status"))
winStatus := strings.TrimSpace(c.GetString("win_status"))
sourceType := strings.TrimSpace(c.GetString("source_type"))
ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
kw := orm.NewCondition().
Or("title__contains", keyword).
Or("bid_no__contains", keyword).
Or("customer_name__contains", keyword).
Or("business_name__contains", keyword).
Or("contact_person__contains", keyword)
cond = cond.AndCond(kw)
}
if status != "" {
cond = cond.And("status", status)
}
if winStatus != "" {
cond = cond.And("win_status", winStatus)
}
if sourceType != "" {
if v, errParse := strconv.Atoi(sourceType); errParse == nil {
cond = cond.And("source_type", int8(v))
}
}
if ownerID != "" {
cond = cond.And("owner_user_id", ownerID)
}
qs := models.Orm.QueryTable(new(models.TenantCrmBiddingBid)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmBiddingBid
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize,
})
}
// Detail GET /backend/crm/bidding/bid/detail/:id
func (c *BackendCrmBiddingBidController) Detail() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var row models.TenantCrmBiddingBid
err = models.Orm.QueryTable(new(models.TenantCrmBiddingBid)).
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
Filter("delete_time__isnull", true).One(&row)
if err != nil {
pipelineErr(&c.Controller, 404, 404, "投标记录未找到")
return
}
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权访问该投标数据")
return
}
pipelineOk(&c.Controller, row)
}
// Create POST /backend/crm/bidding/bid
func (c *BackendCrmBiddingBidController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p bidPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if strings.TrimSpace(p.Title) == "" {
pipelineErr(&c.Controller, 400, 400, "投标标题不能为空")
return
}
if strings.TrimSpace(p.CustomerName) == "" {
pipelineErr(&c.Controller, 400, 400, "请填写招标方名称")
return
}
tenantID := pipelineTenantID(claims)
now := time.Now()
row := models.TenantCrmBiddingBid{
TenantID: tenantID,
BidNo: strings.TrimSpace(p.BidNo),
Title: strings.TrimSpace(p.Title),
BidType: firstNonEmpty(p.BidType, "1"),
Status: 0,
SourceType: p.SourceType,
BusinessID: nilOrID(p.BusinessID),
BusinessName: strings.TrimSpace(p.BusinessName),
ProjectID: nilOrID(p.ProjectID),
ProjectName: strings.TrimSpace(p.ProjectName),
TenderID: nilOrID(p.TenderID),
TenderNo: strings.TrimSpace(p.TenderNo),
CustomerID: nilOrID(p.CustomerID),
CustomerName: strings.TrimSpace(p.CustomerName),
PublishOrg: strings.TrimSpace(p.PublishOrg),
BidAmount: p.BidAmount,
BidDeposit: p.BidDeposit,
PublishDate: parsePipelineDate(p.PublishDate),
RegisterEndDate: parsePipelineDateTime(p.RegisterEndDate),
BidEndDate: parsePipelineDateTime(p.BidEndDate),
OpenBidDate: parsePipelineDateTime(p.OpenBidDate),
ContactPerson: strings.TrimSpace(p.ContactPerson),
ContactPhone: strings.TrimSpace(p.ContactPhone),
BidAddress: strings.TrimSpace(p.BidAddress),
OpenAddress: strings.TrimSpace(p.OpenAddress),
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
Description: p.Description,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateUserName: resolveUserName(claims),
CreateTime: now,
UpdateTime: now,
}
// 唯一性校验:同租户内不允许重复创建同名投标
if crmNameDuplicated(new(models.TenantCrmBiddingBid), tenantID, "title", row.Title) {
pipelineErr(&c.Controller, 409, 409, "该投标标题已存在")
return
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, uint64(id), "create", "创建投标:"+row.Title, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Update PUT /backend/crm/bidding/bid/:id
func (c *BackendCrmBiddingBidController) Update() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, 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 bidPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmBiddingBid
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingBid)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "投标记录未找到")
return
}
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "无权操作该投标数据")
return
}
if row.Locked == 1 || row.Status == 6 {
pipelineErr(&c.Controller, 400, 400, "投标记录已锁定或已转合同,不能编辑")
return
}
if strings.TrimSpace(p.Title) == "" {
pipelineErr(&c.Controller, 400, 400, "投标标题不能为空")
return
}
row.BidNo = strings.TrimSpace(p.BidNo)
row.Title = strings.TrimSpace(p.Title)
if strings.TrimSpace(p.BidType) != "" {
row.BidType = p.BidType
}
if p.SourceType > 0 {
row.SourceType = p.SourceType
}
row.BusinessID = nilOrID(p.BusinessID)
row.BusinessName = strings.TrimSpace(p.BusinessName)
row.ProjectID = nilOrID(p.ProjectID)
row.ProjectName = strings.TrimSpace(p.ProjectName)
row.TenderID = nilOrID(p.TenderID)
row.TenderNo = strings.TrimSpace(p.TenderNo)
row.CustomerID = nilOrID(p.CustomerID)
row.CustomerName = strings.TrimSpace(p.CustomerName)
row.PublishOrg = strings.TrimSpace(p.PublishOrg)
row.BidAmount = p.BidAmount
row.BidDeposit = p.BidDeposit
row.PublishDate = parsePipelineDate(p.PublishDate)
row.RegisterEndDate = parsePipelineDateTime(p.RegisterEndDate)
row.BidEndDate = parsePipelineDateTime(p.BidEndDate)
row.OpenBidDate = parsePipelineDateTime(p.OpenBidDate)
row.ContactPerson = strings.TrimSpace(p.ContactPerson)
row.ContactPhone = strings.TrimSpace(p.ContactPhone)
row.BidAddress = strings.TrimSpace(p.BidAddress)
row.OpenAddress = strings.TrimSpace(p.OpenAddress)
if strings.TrimSpace(p.OwnerUserID) != "" {
row.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
}
if strings.TrimSpace(p.OwnerUserName) != "" {
row.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
}
row.Description = p.Description
row.Remark = p.Remark
row.UpdateTime = time.Now()
if _, err := models.Orm.Update(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, row.ID, "update", "更新投标:"+row.Title, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
// Delete DELETE /backend/crm/bidding/bid/:id
func (c *BackendCrmBiddingBidController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmBiddingBid
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingBid)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "投标记录未找到")
return
}
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "无权操作该投标数据")
return
}
if row.Locked == 1 || row.Status == 6 {
pipelineErr(&c.Controller, 400, 400, "投标记录已锁定或已转合同,不能删除")
return
}
if !canDeleteCrmRecord(claims, row.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "只有创建人、租户管理员或平台管理员可以删除该投标")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmBiddingBid)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
pipelineOk(&c.Controller, nil)
}
// ConvertContract POST /backend/crm/bidding/bid/:id/contract 中标转合同
func (c *BackendCrmBiddingBidController) ConvertContract() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var p struct {
ContractName string `json:"contract_name"`
ContractNo string `json:"contract_no"`
ContractCategory string `json:"contract_category"`
SignDate string `json:"sign_date"`
Remark string `json:"remark"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var bid models.TenantCrmBiddingBid
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingBid)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&bid); err != nil {
pipelineErr(&c.Controller, 404, 404, "投标记录未找到")
return
}
if !canAccessCrmRecord(claims, bid.OwnerUserID, bid.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "无权操作该投标数据")
return
}
if bid.WinStatus != 1 {
pipelineErr(&c.Controller, 400, 400, "只有中标的投标才能转合同")
return
}
if bid.ContractID != nil && *bid.ContractID > 0 {
pipelineErr(&c.Controller, 400, 400, "该投标已转合同,不能重复转")
return
}
contractName := strings.TrimSpace(p.ContractName)
if contractName == "" {
contractName = bid.Title
}
category := strings.TrimSpace(p.ContractCategory)
if category == "" {
category = "2" // 默认:服务类
}
now := time.Now()
bidIDVal := bid.ID
contract := models.TenantCrmContract{
TenantID: tenantID,
ContractNo: strings.TrimSpace(p.ContractNo),
ContractName: contractName,
ContractCategory: category,
OurRole: 2, // 我方为乙方
PartyCount: 2,
ProjectID: bid.ProjectID, // 中标项目关联
ProjectName: bid.ProjectName,
OwnerUserID: bid.OwnerUserID,
OwnerUserName: bid.OwnerUserName,
SignDate: parsePipelineDate(p.SignDate),
Step: 1,
Status: 1,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
}
contractID, err := models.Orm.Insert(&contract)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建合同失败: "+err.Error())
return
}
// 更新投标状态:状态置6已转合同,存 contract_id,加锁
cid := uint64(contractID)
bid.ContractID = &cid
bid.Status = 6
bid.Locked = 1
bidIDForLog := bidIDVal
bid.UpdateTime = now
if _, err := models.Orm.Update(&bid, "contract_id", "status", "locked", "update_time"); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新投标状态失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, bidIDForLog, "convert-contract", "中标转合同:"+contractName, claims)
crmWriteLog(tenantID, 1, cid, "create", "由投标「"+bid.Title+"」中标转化生成", claims)
pipelineOk(&c.Controller, map[string]interface{}{"contract_id": contractID})
}
// ConvertProject POST /backend/crm/bidding/bid/:id/project 中标转项目
func (c *BackendCrmBiddingBidController) ConvertProject() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var p struct {
ProjectName string `json:"project_name"`
ProjectNo string `json:"project_no"`
Amount float64 `json:"amount"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Remark string `json:"remark"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var bid models.TenantCrmBiddingBid
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingBid)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&bid); err != nil {
pipelineErr(&c.Controller, 404, 404, "投标记录未找到")
return
}
if !canAccessCrmRecord(claims, bid.OwnerUserID, bid.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "无权操作该投标数据")
return
}
if bid.WinStatus != 1 {
pipelineErr(&c.Controller, 400, 400, "只有中标的投标才能转项目")
return
}
if bid.NewProjectID != nil && *bid.NewProjectID > 0 {
pipelineErr(&c.Controller, 400, 400, "该投标已转项目,不能重复转")
return
}
projectName := strings.TrimSpace(p.ProjectName)
if projectName == "" {
projectName = bid.Title
}
amount := p.Amount
if amount == 0 {
amount = bid.BidAmount
}
now := time.Now()
bidIDVal := bid.ID
proj := models.TenantCrmProject{
TenantID: tenantID,
ProjectName: projectName,
ProjectNo: strings.TrimSpace(p.ProjectNo),
ProjectType: 1, // 单一项目
BusinessID: bid.BusinessID,
CustomerID: bid.CustomerID,
CustomerName: bid.CustomerName,
OwnerUserID: bid.OwnerUserID,
OwnerUserName: bid.OwnerUserName,
UpstreamAmount: amount,
Amount: amount,
Status: 1,
StartDate: parsePipelineDate(p.StartDate),
EndDate: parsePipelineDate(p.EndDate),
ContactPerson: bid.ContactPerson,
ContactPhone: bid.ContactPhone,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
}
projID, err := models.Orm.Insert(&proj)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建项目失败: "+err.Error())
return
}
pid := uint64(projID)
bid.NewProjectID = &pid
bid.ProjectID = &pid
bid.ProjectName = projectName
bid.UpdateTime = now
if _, err := models.Orm.Update(&bid, "new_project_id", "project_id", "project_name", "update_time"); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新投标状态失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, bidIDVal, "convert-project", "中标转项目:"+projectName, claims)
crmWriteLog(tenantID, 3, pid, "create", "由投标「"+bid.Title+"」中标转化生成", claims)
pipelineOk(&c.Controller, map[string]interface{}{"project_id": projID})
}
// SubmitResult POST /backend/crm/bidding/bid/:id/result 提交开标结果
func (c *BackendCrmBiddingBidController) SubmitResult() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var p struct {
ResultType int8 `json:"result_type"`
AnnounceDate string `json:"announce_date"`
AnnounceURL string `json:"announce_url"`
TotalScore float64 `json:"total_score"`
OurRanking uint32 `json:"our_ranking"`
CompetitorCount uint32 `json:"competitor_count"`
Reason string `json:"reason"`
Lessons string `json:"lessons"`
NextStep string `json:"next_step"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var bid models.TenantCrmBiddingBid
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingBid)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&bid); err != nil {
pipelineErr(&c.Controller, 404, 404, "投标记录未找到")
return
}
if !canAccessCrmRecord(claims, bid.OwnerUserID, bid.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "无权操作该投标数据")
return
}
now := time.Now()
result := models.TenantCrmBiddingBidResult{
TenantID: tenantID,
BidID: bid.ID,
ResultType: p.ResultType,
AnnounceDate: parsePipelineDateTime(p.AnnounceDate),
AnnounceURL: strings.TrimSpace(p.AnnounceURL),
TotalScore: p.TotalScore,
OurRanking: p.OurRanking,
CompetitorCount: p.CompetitorCount,
Reason: p.Reason,
Lessons: p.Lessons,
NextStep: strings.TrimSpace(p.NextStep),
CreateUserID: pipelineUID(claims),
CreateUserName: resolveUserName(claims),
CreateTime: now,
UpdateTime: now,
}
resultID, err := models.Orm.Insert(&result)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "保存投标结果失败: "+err.Error())
return
}
// 同步投标主表状态/得分
bid.WinStatus = p.ResultType
bid.WinDate = parsePipelineDateTime(p.AnnounceDate)
bid.Status = 3 // 已开标
bid.TotalScore = p.TotalScore
bid.OurRanking = p.OurRanking
bid.CompetitorCount = p.CompetitorCount
if p.ResultType == 1 {
bid.Status = 4 // 中标
} else if p.ResultType == 2 {
bid.Status = 5 // 落标
}
bid.UpdateTime = now
if _, err := models.Orm.Update(&bid, "win_status", "win_date", "status", "total_score", "our_ranking", "competitor_count", "update_time"); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新投标状态失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, bid.ID, "submit-result",
fmt.Sprintf("提交开标结果:%s(%s)", resultTypeText(p.ResultType), bid.Title), claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": resultID})
}
// FromBusiness POST /backend/crm/bidding/bid/from-business 从商机创建投标
func (c *BackendCrmBiddingBidController) FromBusiness() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
var p struct {
BusinessID uint64 `json:"business_id"`
Title string `json:"title"`
CustomerID uint64 `json:"customer_id"`
CustomerName string `json:"customer_name"`
PublishOrg string `json:"publish_org"`
BidType string `json:"bid_type"`
BidAmount float64 `json:"bid_amount"`
BidEndDate string `json:"bid_end_date"`
OpenBidDate string `json:"open_bid_date"`
OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"`
Description string `json:"description"`
Remark string `json:"remark"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.BusinessID == 0 {
pipelineErr(&c.Controller, 400, 400, "请选择商机")
return
}
tenantID := pipelineTenantID(claims)
var biz models.TenantCrmBusiness
if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
Filter("id", p.BusinessID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&biz); err != nil {
pipelineErr(&c.Controller, 404, 404, "商机未找到")
return
}
if biz.Locked == 1 {
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能再发起投标")
return
}
now := time.Now()
title := strings.TrimSpace(p.Title)
if title == "" {
title = biz.BusinessName
}
bizID := biz.ID
row := models.TenantCrmBiddingBid{
TenantID: tenantID,
Title: title,
BidType: firstNonEmpty(p.BidType, "1"),
Status: 0,
SourceType: 1, // 1=商机
SourceID: &bizID,
BusinessID: &bizID,
BusinessName: biz.BusinessName,
CustomerID: biz.CustomerID,
CustomerName: firstNonEmpty(p.CustomerName, biz.CustomerName),
PublishOrg: firstNonEmpty(p.PublishOrg, biz.CustomerName),
BidAmount: p.BidAmount,
BidEndDate: parsePipelineDateTime(p.BidEndDate),
OpenBidDate: parsePipelineDateTime(p.OpenBidDate),
ContactPerson: biz.ContactPerson,
ContactPhone: biz.ContactPhone,
OwnerUserID: firstNonEmpty(p.OwnerUserID, biz.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, biz.OwnerUserName, resolveUserName(claims)),
Description: p.Description,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateUserName: resolveUserName(claims),
CreateTime: now,
UpdateTime: now,
}
if crmNameDuplicated(new(models.TenantCrmBiddingBid), tenantID, "title", row.Title) {
pipelineErr(&c.Controller, 409, 409, "该投标标题已存在")
return
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, uint64(id), "from-business", "从商机「"+biz.BusinessName+"」创建投标", claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id, "business": biz})
}
// FromProject POST /backend/crm/bidding/bid/from-project 从项目创建投标
func (c *BackendCrmBiddingBidController) FromProject() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
var p struct {
ProjectID uint64 `json:"project_id"`
Title string `json:"title"`
BidType string `json:"bid_type"`
BidAmount float64 `json:"bid_amount"`
BidEndDate string `json:"bid_end_date"`
OpenBidDate string `json:"open_bid_date"`
OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"`
Description string `json:"description"`
Remark string `json:"remark"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.ProjectID == 0 {
pipelineErr(&c.Controller, 400, 400, "请选择项目")
return
}
tenantID := pipelineTenantID(claims)
var proj models.TenantCrmProject
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
Filter("id", p.ProjectID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&proj); err != nil {
pipelineErr(&c.Controller, 404, 404, "项目未找到")
return
}
now := time.Now()
title := strings.TrimSpace(p.Title)
if title == "" {
title = proj.ProjectName
}
projID := proj.ID
row := models.TenantCrmBiddingBid{
TenantID: tenantID,
Title: title,
BidType: firstNonEmpty(p.BidType, "1"),
Status: 0,
SourceType: 2, // 2=项目
SourceID: &projID,
ProjectID: &projID,
ProjectName: proj.ProjectName,
BusinessID: proj.BusinessID,
BusinessName: "", // 需要回查商机名(不阻塞,简化处理)
CustomerID: proj.CustomerID,
CustomerName: proj.CustomerName,
BidAmount: p.BidAmount,
BidEndDate: parsePipelineDateTime(p.BidEndDate),
OpenBidDate: parsePipelineDateTime(p.OpenBidDate),
OwnerUserID: firstNonEmpty(p.OwnerUserID, proj.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, proj.OwnerUserName, resolveUserName(claims)),
Description: p.Description,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateUserName: resolveUserName(claims),
CreateTime: now,
UpdateTime: now,
}
if crmNameDuplicated(new(models.TenantCrmBiddingBid), tenantID, "title", row.Title) {
pipelineErr(&c.Controller, 409, 409, "该投标标题已存在")
return
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(tenantID, 4, uint64(id), "from-project", "从项目「"+proj.ProjectName+"」创建投标", claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id, "project": proj})
}
// =====================================================================
// 辅助函数
// =====================================================================
// nilOrID 当 id>0 时返回指针,否则返回 nil
func nilOrID(id uint64) *uint64 {
if id == 0 {
return nil
}
v := id
return &v
}
// resultTypeText 投标结果文本
func resultTypeText(t int8) string {
switch t {
case 1:
return "中标"
case 2:
return "未中标"
case 3:
return "废标"
case 4:
return "流标"
default:
return "未知"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
package controllers
import (
"sort"
"time"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// CRM 招投标管理 - 到期提醒(开发计划 4.1)
// =============================================================
//
// 不新增表、不引入定时任务:所有提醒都是「按当前时间实时计算」的,
// 避免定时任务没跑导致状态陈旧。前端在招投标首页拉取近 N 天的待办。
//
// 覆盖四类到期:
// 1. 投标:报名截止 / 投标截止 / 开标时间
// 2. 招标:报名截止 / 投标截止 / 开标时间
// 3. 资质:证书到期
//
// GET /backend/crm/bidding/reminders?days=7
// =============================================================
// BackendCrmBiddingReminderController 招投标到期提醒
type BackendCrmBiddingReminderController struct {
beego.Controller
}
// biddingReminderItem 一条到期提醒
type biddingReminderItem struct {
BizType string `json:"biz_type"` // bid | tender | qualification
BizID uint64 `json:"biz_id"` // 业务记录ID
Kind string `json:"kind"` // register_end | bid_end | open_bid | expire
KindName string `json:"kind_name"` // 提醒类型名称
Title string `json:"title"` // 记录标题
DueDate string `json:"due_date"` // 到期时间 yyyy-MM-dd HH:mm:ss
RemainDays int `json:"remain_days"` // 剩余天数,负值表示已逾期
Level string `json:"level"` // danger(<=1天/已逾期) | warning(<=3天) | info
OwnerName string `json:"owner_user_name"`
}
// List GET /backend/crm/bidding/reminders?days=7
func (c *BackendCrmBiddingReminderController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
tid := pipelineTenantID(claims)
days, _ := c.GetInt("days", 7)
if days <= 0 || days > 365 {
days = 7
}
now := time.Now()
deadline := now.AddDate(0, 0, days)
items := make([]biddingReminderItem, 0, 32)
// ---------- 投标侧 ----------
var bids []models.TenantCrmBiddingBid
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingBid)).
Filter("tenant_id", tid).
Filter("delete_time__isnull", true).
Filter("status__in", 0, 1, 2, 3).
All(&bids)
for _, b := range bids {
if !canAccessCrmRecord(claims, b.OwnerUserID, b.CreateUserID) {
continue
}
items = append(items, pickBidReminders(b, now, deadline)...)
}
// ---------- 招标侧 ----------
var tenders []models.TenantCrmBiddingTender
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingTender)).
Filter("tenant_id", tid).
Filter("delete_time__isnull", true).
Filter("status__in", 0, 1, 2, 3, 4, 5).
All(&tenders)
for _, t := range tenders {
if !canAccessCrmRecord(claims, t.OwnerUserID, t.CreateUserID) {
continue
}
items = append(items, pickTenderReminders(t, now, deadline)...)
}
// ---------- 资质到期 ----------
var quals []models.TenantCrmBiddingBidQualification
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidQualification)).
Filter("tenant_id", tid).
Filter("delete_time__isnull", true).
All(&quals)
for _, q := range quals {
if q.ExpireDate == nil {
continue
}
due := *q.ExpireDate
// 资质到期包含「已过期」,便于用户发现失效证书
if due.After(deadline) {
continue
}
items = append(items, biddingReminderItem{
BizType: "qualification",
BizID: q.ID,
Kind: "expire",
KindName: "资质到期",
Title: q.QualificationName,
DueDate: due.Format("2006-01-02"),
RemainDays: daysBetween(now, due),
Level: levelOf(daysBetween(now, due)),
OwnerName: q.OwnerUserName,
})
}
// 按到期时间升序:最紧急的排在最前
sort.SliceStable(items, func(i, j int) bool {
if items[i].RemainDays != items[j].RemainDays {
return items[i].RemainDays < items[j].RemainDays
}
return items[i].DueDate < items[j].DueDate
})
pipelineOk(&c.Controller, map[string]interface{}{
"list": items,
"total": len(items),
"days": days,
})
}
// pickBidReminders 从一条投标记录中挑出落在窗口内的到期项
func pickBidReminders(b models.TenantCrmBiddingBid, now, deadline time.Time) []biddingReminderItem {
out := make([]biddingReminderItem, 0, 3)
add := func(kind, kindName string, due *time.Time) {
if due == nil {
return
}
d := *due
if d.Before(now) || d.After(deadline) {
return
}
out = append(out, biddingReminderItem{
BizType: "bid",
BizID: b.ID,
Kind: kind,
KindName: kindName,
Title: b.Title,
DueDate: d.Format("2006-01-02 15:04:05"),
RemainDays: daysBetween(now, d),
Level: levelOf(daysBetween(now, d)),
OwnerName: b.OwnerUserName,
})
}
add("register_end", "报名截止", b.RegisterEndDate)
add("bid_end", "投标截止", b.BidEndDate)
add("open_bid", "开标时间", b.OpenBidDate)
return out
}
// pickTenderReminders 从一条招标记录中挑出落在窗口内的到期项
func pickTenderReminders(t models.TenantCrmBiddingTender, now, deadline time.Time) []biddingReminderItem {
out := make([]biddingReminderItem, 0, 3)
add := func(kind, kindName string, due *time.Time) {
if due == nil {
return
}
d := *due
if d.Before(now) || d.After(deadline) {
return
}
out = append(out, biddingReminderItem{
BizType: "tender",
BizID: t.ID,
Kind: kind,
KindName: kindName,
Title: t.Title,
DueDate: d.Format("2006-01-02 15:04:05"),
RemainDays: daysBetween(now, d),
Level: levelOf(daysBetween(now, d)),
OwnerName: t.OwnerUserName,
})
}
add("register_end", "报名截止", t.RegisterEndDate)
add("bid_end", "投标截止", t.BidEndDate)
add("open_bid", "开标时间", t.OpenBidDate)
return out
}
// daysBetween 计算今天(0点)到目标时间的剩余天数
func daysBetween(now, target time.Time) int {
n := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
t := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, target.Location())
return int(t.Sub(n).Hours() / 24)
}
// levelOf 剩余天数 -> 提醒等级
func levelOf(days int) string {
if days <= 1 {
return "danger"
}
if days <= 3 {
return "warning"
}
return "info"
}
+812
View File
@@ -0,0 +1,812 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"strings"
"time"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// CRM 招投标管理 - 子表 CRUD(统一接口)
// =============================================================
//
// 每个子表控制器提供统一的接口:
// - List 按外键 bid_id / tender_id 列出
// - Create 新增(关联外键由请求体传入)
// - Delete 软删除
// - Update 更新(用于文件改名、任务进度等)
// =============================================================
// ------------------------------ 投标文件 ------------------------------
// BackendCrmBiddingBidFileController 投标文件
type BackendCrmBiddingBidFileController struct {
beego.Controller
}
// bidFilePayload
type bidFilePayload struct {
BidID uint64 `json:"bid_id"`
FileType string `json:"file_type"`
FileName string `json:"file_name"`
FileURL string `json:"file_url"`
FileSize uint64 `json:"file_size"`
FileExt string `json:"file_ext"`
Version string `json:"version"`
UploadUserID string `json:"upload_user_id"`
UploadUserName string `json:"upload_user_name"`
Remark string `json:"remark"`
}
// List GET /backend/crm/bidding/bid-file/list?bid_id=1
func (c *BackendCrmBiddingBidFileController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
bidID, _ := c.GetInt64("bid_id")
if bidID == 0 {
pipelineErr(&c.Controller, 400, 400, "bid_id 必填")
return
}
var list []models.TenantCrmBiddingBidFile
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidFile)).
Filter("tenant_id", pipelineTenantID(claims)).
Filter("bid_id", bidID).
Filter("delete_time__isnull", true).
OrderBy("-id").All(&list)
pipelineOk(&c.Controller, map[string]interface{}{"list": list})
}
// Create POST /backend/crm/bidding/bid-file
func (c *BackendCrmBiddingBidFileController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p bidFilePayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.BidID == 0 {
pipelineErr(&c.Controller, 400, 400, "bid_id 必填")
return
}
if strings.TrimSpace(p.FileName) == "" || strings.TrimSpace(p.FileURL) == "" {
pipelineErr(&c.Controller, 400, 400, "文件信息不完整")
return
}
now := time.Now()
row := models.TenantCrmBiddingBidFile{
TenantID: pipelineTenantID(claims),
BidID: p.BidID,
FileType: firstNonEmpty(p.FileType, "1"),
FileName: strings.TrimSpace(p.FileName),
FileURL: strings.TrimSpace(p.FileURL),
FileSize: p.FileSize,
FileExt: strings.TrimSpace(p.FileExt),
Version: firstNonEmpty(p.Version, "1.0"),
UploadUserID: firstNonEmpty(p.UploadUserID, pipelineUID(claims)),
UploadUserName: firstNonEmpty(p.UploadUserName, resolveUserName(claims)),
Remark: p.Remark,
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "上传失败: "+err.Error())
return
}
crmWriteLog(pipelineTenantID(claims), 5, p.BidID, "create", "上传投标文件", claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Delete DELETE /backend/crm/bidding/bid-file/:id
func (c *BackendCrmBiddingBidFileController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmBiddingBidFile)).
Filter("id", id).
Filter("tenant_id", pipelineTenantID(claims)).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
var delRow models.TenantCrmBiddingBidFile
_ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidFile)).Filter("id", id).One(&delRow)
crmWriteLog(pipelineTenantID(claims), 5, delRow.BidID, "delete", "删除投标文件", claims)
pipelineOk(&c.Controller, nil)
}
// ------------------------------ 投标任务 ------------------------------
// BackendCrmBiddingBidTaskController 投标任务
type BackendCrmBiddingBidTaskController struct {
beego.Controller
}
type bidTaskPayload struct {
BidID uint64 `json:"bid_id"`
TaskName string `json:"task_name"`
TaskType string `json:"task_type"`
Content string `json:"content"`
OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Status int8 `json:"status"`
Progress int8 `json:"progress"`
Files string `json:"files"`
Remark string `json:"remark"`
}
// List
func (c *BackendCrmBiddingBidTaskController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
bidID, _ := c.GetInt64("bid_id")
if bidID == 0 {
pipelineErr(&c.Controller, 400, 400, "bid_id 必填")
return
}
var list []models.TenantCrmBiddingBidTask
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidTask)).
Filter("tenant_id", pipelineTenantID(claims)).
Filter("bid_id", bidID).
Filter("delete_time__isnull", true).
OrderBy("-id").All(&list)
pipelineOk(&c.Controller, map[string]interface{}{"list": list})
}
// Create
func (c *BackendCrmBiddingBidTaskController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p bidTaskPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.BidID == 0 || strings.TrimSpace(p.TaskName) == "" {
pipelineErr(&c.Controller, 400, 400, "任务信息不完整")
return
}
now := time.Now()
row := models.TenantCrmBiddingBidTask{
TenantID: pipelineTenantID(claims),
BidID: p.BidID,
TaskName: strings.TrimSpace(p.TaskName),
TaskType: firstNonEmpty(p.TaskType, "1"),
Content: p.Content,
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
StartDate: parsePipelineDate(p.StartDate),
EndDate: parsePipelineDate(p.EndDate),
Status: p.Status,
Progress: p.Progress,
Files: p.Files,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateUserName: resolveUserName(claims),
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(pipelineTenantID(claims), 5, p.BidID, "create", "新建投标任务", claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Update
func (c *BackendCrmBiddingBidTaskController) Update() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, 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 bidTaskPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
var row models.TenantCrmBiddingBidTask
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingBidTask)).
Filter("id", id).
Filter("tenant_id", pipelineTenantID(claims)).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "任务未找到")
return
}
if strings.TrimSpace(p.TaskName) != "" {
row.TaskName = strings.TrimSpace(p.TaskName)
}
if strings.TrimSpace(p.TaskType) != "" {
row.TaskType = p.TaskType
}
row.Content = p.Content
if strings.TrimSpace(p.OwnerUserID) != "" {
row.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
row.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
}
row.StartDate = parsePipelineDate(p.StartDate)
row.EndDate = parsePipelineDate(p.EndDate)
row.Status = p.Status
row.Progress = p.Progress
row.Files = p.Files
row.Remark = p.Remark
row.UpdateTime = time.Now()
if _, err := models.Orm.Update(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
crmWriteLog(pipelineTenantID(claims), 5, row.BidID, "update", "更新投标任务", claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
// Delete
func (c *BackendCrmBiddingBidTaskController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmBiddingBidTask)).
Filter("id", id).
Filter("tenant_id", pipelineTenantID(claims)).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
var delRow models.TenantCrmBiddingBidTask
_ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidTask)).Filter("id", id).One(&delRow)
crmWriteLog(pipelineTenantID(claims), 5, delRow.BidID, "delete", "删除投标任务", claims)
pipelineOk(&c.Controller, nil)
}
// ------------------------------ 投标结果 ------------------------------
// BackendCrmBiddingBidResultController 投标结果
type BackendCrmBiddingBidResultController struct {
beego.Controller
}
// List
func (c *BackendCrmBiddingBidResultController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
bidID, _ := c.GetInt64("bid_id")
if bidID == 0 {
pipelineErr(&c.Controller, 400, 400, "bid_id 必填")
return
}
var list []models.TenantCrmBiddingBidResult
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidResult)).
Filter("tenant_id", pipelineTenantID(claims)).
Filter("bid_id", bidID).
Filter("delete_time__isnull", true).
OrderBy("-id").All(&list)
pipelineOk(&c.Controller, map[string]interface{}{"list": list})
}
// Detail
func (c *BackendCrmBiddingBidResultController) Detail() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var row models.TenantCrmBiddingBidResult
err = models.Orm.QueryTable(new(models.TenantCrmBiddingBidResult)).
Filter("id", id).
Filter("tenant_id", pipelineTenantID(claims)).
Filter("delete_time__isnull", true).One(&row)
if err != nil {
pipelineErr(&c.Controller, 404, 404, "投标结果未找到")
return
}
pipelineOk(&c.Controller, row)
}
// ------------------------------ 投标报价 ------------------------------
// BackendCrmBiddingBidQuoteController 投标报价
type BackendCrmBiddingBidQuoteController struct {
beego.Controller
}
type bidQuotePayload struct {
BidID uint64 `json:"bid_id"`
QuoteName string `json:"quote_name"`
TotalAmount float64 `json:"total_amount"`
Currency string `json:"currency"`
TaxRate float64 `json:"tax_rate"`
ValidDate string `json:"valid_date"`
Products string `json:"products"`
CostAnalysis string `json:"cost_analysis"`
ProfitRate float64 `json:"profit_rate"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
// List
func (c *BackendCrmBiddingBidQuoteController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
bidID, _ := c.GetInt64("bid_id")
if bidID == 0 {
pipelineErr(&c.Controller, 400, 400, "bid_id 必填")
return
}
var list []models.TenantCrmBiddingBidQuote
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidQuote)).
Filter("tenant_id", pipelineTenantID(claims)).
Filter("bid_id", bidID).
Filter("delete_time__isnull", true).
OrderBy("-id").All(&list)
pipelineOk(&c.Controller, map[string]interface{}{"list": list})
}
// Create
func (c *BackendCrmBiddingBidQuoteController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p bidQuotePayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.BidID == 0 || strings.TrimSpace(p.QuoteName) == "" {
pipelineErr(&c.Controller, 400, 400, "报价信息不完整")
return
}
now := time.Now()
row := models.TenantCrmBiddingBidQuote{
TenantID: pipelineTenantID(claims),
BidID: p.BidID,
QuoteName: strings.TrimSpace(p.QuoteName),
TotalAmount: p.TotalAmount,
Currency: firstNonEmpty(p.Currency, "CNY"),
TaxRate: p.TaxRate,
ValidDate: parsePipelineDate(p.ValidDate),
Products: p.Products,
CostAnalysis: p.CostAnalysis,
ProfitRate: p.ProfitRate,
Status: 1,
SubmitUserID: pipelineUID(claims),
SubmitUserName: resolveUserName(claims),
SubmitTime: &now,
Remark: p.Remark,
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(pipelineTenantID(claims), 5, p.BidID, "create", "新建投标报价", claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Delete
func (c *BackendCrmBiddingBidQuoteController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmBiddingBidQuote)).
Filter("id", id).
Filter("tenant_id", pipelineTenantID(claims)).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
var delRow models.TenantCrmBiddingBidQuote
_ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidQuote)).Filter("id", id).One(&delRow)
crmWriteLog(pipelineTenantID(claims), 5, delRow.BidID, "delete", "删除投标报价", claims)
pipelineOk(&c.Controller, nil)
}
// ------------------------------ 投标竞争对手 ------------------------------
// BackendCrmBiddingBidCompetitorController
type BackendCrmBiddingBidCompetitorController struct {
beego.Controller
}
type bidCompetitorPayload struct {
BidID uint64 `json:"bid_id"`
CompetitorName string `json:"competitor_name"`
CompetitorType int8 `json:"competitor_type"`
QuoteAmount float64 `json:"quote_amount"`
Advantages string `json:"advantages"`
Weaknesses string `json:"weaknesses"`
Source string `json:"source"`
Remark string `json:"remark"`
}
// List
func (c *BackendCrmBiddingBidCompetitorController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
bidID, _ := c.GetInt64("bid_id")
if bidID == 0 {
pipelineErr(&c.Controller, 400, 400, "bid_id 必填")
return
}
var list []models.TenantCrmBiddingBidCompetitor
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidCompetitor)).
Filter("tenant_id", pipelineTenantID(claims)).
Filter("bid_id", bidID).
Filter("delete_time__isnull", true).
OrderBy("-id").All(&list)
pipelineOk(&c.Controller, map[string]interface{}{"list": list})
}
// Create
func (c *BackendCrmBiddingBidCompetitorController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p bidCompetitorPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.BidID == 0 || strings.TrimSpace(p.CompetitorName) == "" {
pipelineErr(&c.Controller, 400, 400, "竞争对手信息不完整")
return
}
now := time.Now()
row := models.TenantCrmBiddingBidCompetitor{
TenantID: pipelineTenantID(claims),
BidID: p.BidID,
CompetitorName: strings.TrimSpace(p.CompetitorName),
CompetitorType: firstNonEmptyInt8(p.CompetitorType, 1),
QuoteAmount: p.QuoteAmount,
Advantages: p.Advantages,
Weaknesses: p.Weaknesses,
Source: strings.TrimSpace(p.Source),
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateUserName: resolveUserName(claims),
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(pipelineTenantID(claims), 5, p.BidID, "create", "新增竞争对手", claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Delete
func (c *BackendCrmBiddingBidCompetitorController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmBiddingBidCompetitor)).
Filter("id", id).
Filter("tenant_id", pipelineTenantID(claims)).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
var delRow models.TenantCrmBiddingBidCompetitor
_ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidCompetitor)).Filter("id", id).One(&delRow)
crmWriteLog(pipelineTenantID(claims), 5, delRow.BidID, "delete", "删除竞争对手", claims)
pipelineOk(&c.Controller, nil)
}
// ------------------------------ 投标费用 ------------------------------
// BackendCrmBiddingBidExpenseController
type BackendCrmBiddingBidExpenseController struct {
beego.Controller
}
type bidExpensePayload struct {
BidID uint64 `json:"bid_id"`
ExpenseType int8 `json:"expense_type"`
ExpenseName string `json:"expense_name"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
PaymentStatus int8 `json:"payment_status"`
PaymentDate string `json:"payment_date"`
Description string `json:"description"`
VoucherURL string `json:"voucher_url"`
}
// List
func (c *BackendCrmBiddingBidExpenseController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
bidID, _ := c.GetInt64("bid_id")
if bidID == 0 {
pipelineErr(&c.Controller, 400, 400, "bid_id 必填")
return
}
var list []models.TenantCrmBiddingBidExpense
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidExpense)).
Filter("tenant_id", pipelineTenantID(claims)).
Filter("bid_id", bidID).
Filter("delete_time__isnull", true).
OrderBy("-id").All(&list)
pipelineOk(&c.Controller, map[string]interface{}{"list": list})
}
// Create
func (c *BackendCrmBiddingBidExpenseController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p bidExpensePayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.BidID == 0 || strings.TrimSpace(p.ExpenseName) == "" {
pipelineErr(&c.Controller, 400, 400, "费用信息不完整")
return
}
now := time.Now()
row := models.TenantCrmBiddingBidExpense{
TenantID: pipelineTenantID(claims),
BidID: p.BidID,
ExpenseType: firstNonEmptyInt8(p.ExpenseType, 1),
ExpenseName: strings.TrimSpace(p.ExpenseName),
Amount: p.Amount,
Currency: firstNonEmpty(p.Currency, "CNY"),
PaymentStatus: p.PaymentStatus,
PaymentDate: parsePipelineDateTime(p.PaymentDate),
Description: p.Description,
VoucherURL: strings.TrimSpace(p.VoucherURL),
CreateUserID: pipelineUID(claims),
CreateUserName: resolveUserName(claims),
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(pipelineTenantID(claims), 5, p.BidID, "create", "登记投标费用", claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Delete
func (c *BackendCrmBiddingBidExpenseController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmBiddingBidExpense)).
Filter("id", id).
Filter("tenant_id", pipelineTenantID(claims)).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
var delRow models.TenantCrmBiddingBidExpense
_ = models.Orm.QueryTable(new(models.TenantCrmBiddingBidExpense)).Filter("id", id).One(&delRow)
crmWriteLog(pipelineTenantID(claims), 5, delRow.BidID, "delete", "删除投标费用", claims)
pipelineOk(&c.Controller, nil)
}
// ------------------------------ 招标文件 ------------------------------
// BackendCrmBiddingTenderFileController 招标文件
type BackendCrmBiddingTenderFileController struct {
beego.Controller
}
type tenderFilePayload struct {
TenderID uint64 `json:"tender_id"`
FileType string `json:"file_type"`
FileName string `json:"file_name"`
FileURL string `json:"file_url"`
FileSize uint64 `json:"file_size"`
FileExt string `json:"file_ext"`
UploadUserID string `json:"upload_user_id"`
UploadUserName string `json:"upload_user_name"`
Remark string `json:"remark"`
}
// List
func (c *BackendCrmBiddingTenderFileController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
tenderID, _ := c.GetInt64("tender_id")
if tenderID == 0 {
pipelineErr(&c.Controller, 400, 400, "tender_id 必填")
return
}
var list []models.TenantCrmBiddingTenderFile
_, _ = models.Orm.QueryTable(new(models.TenantCrmBiddingTenderFile)).
Filter("tenant_id", pipelineTenantID(claims)).
Filter("tender_id", tenderID).
Filter("delete_time__isnull", true).
OrderBy("-id").All(&list)
pipelineOk(&c.Controller, map[string]interface{}{"list": list})
}
// Create
func (c *BackendCrmBiddingTenderFileController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p tenderFilePayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.TenderID == 0 || strings.TrimSpace(p.FileName) == "" || strings.TrimSpace(p.FileURL) == "" {
pipelineErr(&c.Controller, 400, 400, "文件信息不完整")
return
}
now := time.Now()
row := models.TenantCrmBiddingTenderFile{
TenantID: pipelineTenantID(claims),
TenderID: p.TenderID,
FileType: firstNonEmpty(p.FileType, "1"),
FileName: strings.TrimSpace(p.FileName),
FileURL: strings.TrimSpace(p.FileURL),
FileSize: p.FileSize,
FileExt: strings.TrimSpace(p.FileExt),
UploadUserID: firstNonEmpty(p.UploadUserID, pipelineUID(claims)),
UploadUserName: firstNonEmpty(p.UploadUserName, resolveUserName(claims)),
Remark: p.Remark,
CreateTime: now,
UpdateTime: now,
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "上传失败: "+err.Error())
return
}
crmWriteLog(pipelineTenantID(claims), 5, p.TenderID, "create", "上传招标文件", claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Delete
func (c *BackendCrmBiddingTenderFileController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmBiddingTenderFile)).
Filter("id", id).
Filter("tenant_id", pipelineTenantID(claims)).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
var delRow models.TenantCrmBiddingTenderFile
_ = models.Orm.QueryTable(new(models.TenantCrmBiddingTenderFile)).Filter("id", id).One(&delRow)
crmWriteLog(pipelineTenantID(claims), 5, delRow.TenderID, "delete", "删除招标文件", claims)
pipelineOk(&c.Controller, nil)
}
// =====================================================================
// 工具函数
// =====================================================================
// firstNonEmptyInt8 返回第一个非零的 int8
func firstNonEmptyInt8(v, def int8) int8 {
if v == 0 {
return def
}
return v
}
@@ -0,0 +1,501 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// CRM 招投标管理 - 招标方(Tender)
// =============================================================
//
// 状态机:0草稿 -> 1公告中 -> 2报名中 -> 3投标中 -> 4开标中 -> 5评标中 -> 6已定标 -> 7已归档
// 关联:定标 -> 中标供应商 -> 转合同
// =============================================================
// BackendCrmBiddingTenderController 招标方
type BackendCrmBiddingTenderController struct {
beego.Controller
}
// tenderPayload 创建/更新请求体
type tenderPayload struct {
TenderNo string `json:"tender_no"`
Title string `json:"title"`
TenderType string `json:"tender_type"`
CustomerID uint64 `json:"customer_id"`
CustomerName string `json:"customer_name"`
PublishOrg string `json:"publish_org"`
BudgetAmount float64 `json:"budget_amount"`
BidDeposit float64 `json:"bid_deposit"`
PublishDate string `json:"publish_date"`
RegisterStartDate string `json:"register_start_date"`
RegisterEndDate string `json:"register_end_date"`
BidStartDate string `json:"bid_start_date"`
BidEndDate string `json:"bid_end_date"`
OpenBidDate string `json:"open_bid_date"`
ContactPerson string `json:"contact_person"`
ContactPhone string `json:"contact_phone"`
ContactEmail string `json:"contact_email"`
BidAddress string `json:"bid_address"`
OpenAddress string `json:"open_address"`
Description string `json:"description"`
Requirement string `json:"requirement"`
EvaluationMethod string `json:"evaluation_method"`
OwnerUserID string `json:"owner_user_id"`
OwnerUserName string `json:"owner_user_name"`
Remark string `json:"remark"`
}
// List GET /backend/crm/bidding/tender/list
func (c *BackendCrmBiddingTenderController) List() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
keyword := strings.TrimSpace(c.GetString("keyword"))
status := strings.TrimSpace(c.GetString("status"))
ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
tenantID := pipelineTenantID(claims)
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
cond = crmApplyOwnerScope(cond, claims)
if keyword != "" {
kw := orm.NewCondition().
Or("title__contains", keyword).
Or("tender_no__contains", keyword).
Or("customer_name__contains", keyword).
Or("publish_org__contains", keyword)
cond = cond.AndCond(kw)
}
if status != "" {
if v, errParse := strconv.Atoi(status); errParse == nil {
cond = cond.And("status", int8(v))
}
}
if ownerID != "" {
cond = cond.And("owner_user_id", ownerID)
}
qs := models.Orm.QueryTable(new(models.TenantCrmBiddingTender)).SetCond(cond)
total, _ := qs.Count()
var list []models.TenantCrmBiddingTender
if total > 0 {
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
}
pipelineOk(&c.Controller, map[string]interface{}{
"list": list, "total": total, "page": page, "pageSize": pageSize,
})
}
// Detail GET /backend/crm/bidding/tender/detail/:id
func (c *BackendCrmBiddingTenderController) Detail() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var row models.TenantCrmBiddingTender
err = models.Orm.QueryTable(new(models.TenantCrmBiddingTender)).
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
Filter("delete_time__isnull", true).One(&row)
if err != nil {
pipelineErr(&c.Controller, 404, 404, "招标记录未找到")
return
}
pipelineOk(&c.Controller, row)
}
// Create POST /backend/crm/bidding/tender
func (c *BackendCrmBiddingTenderController) Create() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p tenderPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if strings.TrimSpace(p.Title) == "" {
pipelineErr(&c.Controller, 400, 400, "招标标题不能为空")
return
}
tenantID := pipelineTenantID(claims)
now := time.Now()
row := models.TenantCrmBiddingTender{
TenantID: tenantID,
TenderNo: strings.TrimSpace(p.TenderNo),
Title: strings.TrimSpace(p.Title),
TenderType: firstNonEmpty(p.TenderType, "1"),
Status: 0,
CustomerID: nilOrID(p.CustomerID),
CustomerName: strings.TrimSpace(p.CustomerName),
PublishOrg: firstNonEmpty(p.PublishOrg, p.CustomerName),
BudgetAmount: p.BudgetAmount,
BidDeposit: p.BidDeposit,
PublishDate: parsePipelineDate(p.PublishDate),
RegisterStartDate: parsePipelineDateTime(p.RegisterStartDate),
RegisterEndDate: parsePipelineDateTime(p.RegisterEndDate),
BidStartDate: parsePipelineDateTime(p.BidStartDate),
BidEndDate: parsePipelineDateTime(p.BidEndDate),
OpenBidDate: parsePipelineDateTime(p.OpenBidDate),
ContactPerson: strings.TrimSpace(p.ContactPerson),
ContactPhone: strings.TrimSpace(p.ContactPhone),
ContactEmail: strings.TrimSpace(p.ContactEmail),
BidAddress: strings.TrimSpace(p.BidAddress),
OpenAddress: strings.TrimSpace(p.OpenAddress),
Description: p.Description,
Requirement: p.Requirement,
EvaluationMethod: firstNonEmpty(p.EvaluationMethod, "1"),
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateUserName: resolveUserName(claims),
CreateTime: now,
UpdateTime: now,
}
if crmNameDuplicated(new(models.TenantCrmBiddingTender), tenantID, "title", row.Title) {
pipelineErr(&c.Controller, 409, 409, "该招标标题已存在")
return
}
id, err := models.Orm.Insert(&row)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
return
}
crmWriteLog(tenantID, 5, uint64(id), "create", "创建招标:"+row.Title, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
}
// Update PUT /backend/crm/bidding/tender/:id
func (c *BackendCrmBiddingTenderController) Update() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, 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 tenderPayload
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmBiddingTender
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingTender)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "招标记录未找到")
return
}
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "无权操作该招标数据")
return
}
if row.Locked == 1 || row.Status == 6 {
pipelineErr(&c.Controller, 400, 400, "招标记录已定标锁定,不能编辑")
return
}
if strings.TrimSpace(p.Title) == "" {
pipelineErr(&c.Controller, 400, 400, "招标标题不能为空")
return
}
row.TenderNo = strings.TrimSpace(p.TenderNo)
row.Title = strings.TrimSpace(p.Title)
if strings.TrimSpace(p.TenderType) != "" {
row.TenderType = p.TenderType
}
row.CustomerID = nilOrID(p.CustomerID)
row.CustomerName = strings.TrimSpace(p.CustomerName)
row.PublishOrg = strings.TrimSpace(p.PublishOrg)
row.BudgetAmount = p.BudgetAmount
row.BidDeposit = p.BidDeposit
row.PublishDate = parsePipelineDate(p.PublishDate)
row.RegisterStartDate = parsePipelineDateTime(p.RegisterStartDate)
row.RegisterEndDate = parsePipelineDateTime(p.RegisterEndDate)
row.BidStartDate = parsePipelineDateTime(p.BidStartDate)
row.BidEndDate = parsePipelineDateTime(p.BidEndDate)
row.OpenBidDate = parsePipelineDateTime(p.OpenBidDate)
row.ContactPerson = strings.TrimSpace(p.ContactPerson)
row.ContactPhone = strings.TrimSpace(p.ContactPhone)
row.ContactEmail = strings.TrimSpace(p.ContactEmail)
row.BidAddress = strings.TrimSpace(p.BidAddress)
row.OpenAddress = strings.TrimSpace(p.OpenAddress)
row.Description = p.Description
row.Requirement = p.Requirement
if strings.TrimSpace(p.EvaluationMethod) != "" {
row.EvaluationMethod = p.EvaluationMethod
}
if strings.TrimSpace(p.OwnerUserID) != "" {
row.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
}
if strings.TrimSpace(p.OwnerUserName) != "" {
row.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
}
row.Remark = p.Remark
row.UpdateTime = time.Now()
if _, err := models.Orm.Update(&row); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
return
}
crmWriteLog(tenantID, 5, row.ID, "update", "更新招标:"+row.Title, claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
}
// Delete DELETE /backend/crm/bidding/tender/:id
func (c *BackendCrmBiddingTenderController) Delete() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmBiddingTender
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingTender)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "招标记录未找到")
return
}
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "无权操作该招标数据")
return
}
if row.Locked == 1 || row.Status == 6 {
pipelineErr(&c.Controller, 400, 400, "招标记录已定标锁定,不能删除")
return
}
if !canDeleteCrmRecord(claims, row.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "只有创建人、租户管理员或平台管理员可以删除该招标")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.TenantCrmBiddingTender)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
}
pipelineOk(&c.Controller, nil)
}
// Award POST /backend/crm/bidding/tender/:id/award 定标
func (c *BackendCrmBiddingTenderController) Award() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var p struct {
WinnerSupplierID uint64 `json:"winner_supplier_id"`
WinnerSupplierName string `json:"winner_supplier_name"`
WinnerAmount float64 `json:"winner_amount"`
Announcement string `json:"announcement"`
ResultType int8 `json:"result_type"` // 1定标 2流标 3废标
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmBiddingTender
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingTender)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "招标记录未找到")
return
}
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
pipelineErr(&c.Controller, 401, 401, "无权操作该招标数据")
return
}
if row.Locked == 1 {
pipelineErr(&c.Controller, 400, 400, "招标记录已定标,不能重复定标")
return
}
now := time.Now()
resultType := p.ResultType
if resultType == 0 {
resultType = 1
}
// 写定标结果
result := models.TenantCrmBiddingTenderResult{
TenantID: tenantID,
TenderID: row.ID,
ResultType: resultType,
WinnerSupplierID: nilOrID(p.WinnerSupplierID),
WinnerSupplierName: strings.TrimSpace(p.WinnerSupplierName),
WinnerAmount: p.WinnerAmount,
ResultDate: &now,
Announcement: p.Announcement,
CreateUserID: pipelineUID(claims),
CreateUserName: resolveUserName(claims),
CreateTime: now,
UpdateTime: now,
}
if _, err := models.Orm.Insert(&result); err != nil {
pipelineErr(&c.Controller, 500, 500, "写入定标结果失败: "+err.Error())
return
}
// 更新招标主表:状态 6已定标 + 锁定
row.Status = 6
row.Locked = 1
row.WinnerSupplierID = nilOrID(p.WinnerSupplierID)
row.WinnerSupplierName = strings.TrimSpace(p.WinnerSupplierName)
row.WinnerAmount = p.WinnerAmount
row.WinnerDate = &now
row.UpdateTime = now
if _, err := models.Orm.Update(&row, "status", "locked", "winner_supplier_id", "winner_supplier_name", "winner_amount", "winner_date", "update_time"); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新招标状态失败: "+err.Error())
return
}
crmWriteLog(tenantID, 5, row.ID, "award",
fmt.Sprintf("定标:%s 中标金额 %.2f", p.WinnerSupplierName, p.WinnerAmount), claims)
pipelineOk(&c.Controller, map[string]interface{}{"result_id": result.ID})
}
// AwardToContract POST /backend/crm/bidding/tender/:id/contract 定标转合同
func (c *BackendCrmBiddingTenderController) AwardToContract() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
pipelineErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
var p struct {
ContractName string `json:"contract_name"`
ContractNo string `json:"contract_no"`
ContractCategory string `json:"contract_category"`
SignDate string `json:"sign_date"`
Remark string `json:"remark"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
tenantID := pipelineTenantID(claims)
var row models.TenantCrmBiddingTender
if err := models.Orm.QueryTable(new(models.TenantCrmBiddingTender)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&row); err != nil {
pipelineErr(&c.Controller, 404, 404, "招标记录未找到")
return
}
if row.Status != 6 || row.Locked != 1 {
pipelineErr(&c.Controller, 400, 400, "招标未定标,无法转合同")
return
}
if row.ContractID != nil && *row.ContractID > 0 {
pipelineErr(&c.Controller, 400, 400, "该招标已转合同,不能重复转")
return
}
contractName := strings.TrimSpace(p.ContractName)
if contractName == "" {
contractName = row.Title
}
category := strings.TrimSpace(p.ContractCategory)
if category == "" {
category = "2" // 服务类
}
now := time.Now()
tenderID := row.ID
contract := models.TenantCrmContract{
TenantID: tenantID,
ContractNo: strings.TrimSpace(p.ContractNo),
ContractName: contractName,
ContractCategory: category,
OurRole: 1, // 我方为甲方
PartyCount: 2,
OwnerUserID: row.OwnerUserID,
OwnerUserName: row.OwnerUserName,
SignDate: parsePipelineDate(p.SignDate),
Step: 1,
Status: 1,
Remark: p.Remark,
CreateUserID: pipelineUID(claims),
CreateTime: now,
UpdateTime: now,
}
contractID, err := models.Orm.Insert(&contract)
if err != nil {
pipelineErr(&c.Controller, 500, 500, "创建合同失败: "+err.Error())
return
}
cid := uint64(contractID)
row.ContractID = &cid
row.UpdateTime = now
if _, err := models.Orm.Update(&row, "contract_id", "update_time"); err != nil {
pipelineErr(&c.Controller, 500, 500, "更新招标合同ID失败: "+err.Error())
return
}
crmWriteLog(tenantID, 5, tenderID, "award-contract", "定标转合同:"+contractName, claims)
crmWriteLog(tenantID, 1, cid, "create", "由招标「"+row.Title+"」定标转化生成", claims)
pipelineOk(&c.Controller, map[string]interface{}{"contract_id": contractID})
}