优化回款功能
This commit is contained in:
@@ -531,7 +531,8 @@ func contractStatusName(s int8) string {
|
||||
|
||||
// ========================== 内部辅助 ==========================
|
||||
|
||||
// buildContractResp 组装响应:parties / products 透传 JSON 数组,summary 由金额字段组装。
|
||||
// buildContractResp 组装响应:parties / products 透传 JSON 数组;
|
||||
// 金额字段在返回前按 products 以当前口径重算(单价含税、不再叠加税率),summary 随之组装。
|
||||
func buildContractResp(row *models.TenantCrmContract) contractResp {
|
||||
parties := json.RawMessage("[]")
|
||||
if strings.TrimSpace(row.Parties) != "" {
|
||||
@@ -541,6 +542,11 @@ func buildContractResp(row *models.TenantCrmContract) contractResp {
|
||||
if strings.TrimSpace(row.Products) != "" {
|
||||
products = json.RawMessage(row.Products)
|
||||
}
|
||||
// 返回前按「单价含税、不再叠加税率」的口径重算金额,
|
||||
// 保证列表/详情与产品清单一致,并自动修正历史数据按旧口径(叠加税率)存的金额
|
||||
if strings.TrimSpace(row.Products) != "" && string(products) != "[]" {
|
||||
applyContractAmounts(row)
|
||||
}
|
||||
return contractResp{
|
||||
TenantCrmContract: *row,
|
||||
Parties: parties,
|
||||
@@ -584,9 +590,9 @@ func normalizeContractJSON(raw json.RawMessage) (string, error) {
|
||||
}
|
||||
|
||||
// applyContractAmounts 按产品清单重算各部分金额(与前端 ProductList 汇总口径一致):
|
||||
// 每行含税金额=数量×单价×(1+税率/100);
|
||||
// 硬件部分=Σ硬件含税小计;软件部分=Σ软件含税小计;其他部分=Σ服务/开发/其他含税小计;
|
||||
// 合同总金额(含税)=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本通常不含税,原值汇总);
|
||||
// 清单单价为含税价,每行小计=数量×单价(不再叠加税率,税率仅作开票记录);
|
||||
// 硬件部分=Σ硬件小计;软件部分=Σ软件小计;其他部分=Σ服务/开发/其他小计;
|
||||
// 合同总金额=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本为不含税原值);
|
||||
// 合同总利润=总金额-总成本。
|
||||
// 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他。
|
||||
// contractTreeSellTotal 软件开发模块树售价合计:子节点汇总,叶子=人天×统一人天单价。
|
||||
@@ -642,8 +648,8 @@ func applyContractAmounts(row *models.TenantCrmContract) {
|
||||
qty := toFloat64(item["quantity"])
|
||||
price := toFloat64(item["price"])
|
||||
costPrice := toFloat64(item["cost_price"])
|
||||
taxRate := toFloat64(item["tax_rate"])
|
||||
amount := round2(qty * price * (1 + taxRate/100)) // 含税金额
|
||||
// 清单单价为含税价:小计 = 数量 × 单价,不再叠加税率(税率仅作开票记录,不参与金额计算)
|
||||
amount := round2(qty * price)
|
||||
cost = round2(cost + round2(qty*costPrice))
|
||||
cat := fmt.Sprintf("%v", item["category"])
|
||||
switch {
|
||||
|
||||
@@ -135,7 +135,8 @@ func (c *BackendCrmPaybackController) List() {
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmPayback
|
||||
if total > 0 {
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
// 排序:进行中(1) 在前、已完成(2) 在后,同状态内按最新创建优先
|
||||
_, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
items := make([]paybackResp, 0, len(list))
|
||||
for i := range list {
|
||||
@@ -644,3 +645,188 @@ func (c *BackendCrmPaybackController) fillFromContract(tenantID string, contract
|
||||
func formatAmount(n float64) string {
|
||||
return strconv.FormatFloat(round2(n), 'f', 2, 64)
|
||||
}
|
||||
|
||||
// ========================== 回款记录(回款进度流水) ==========================
|
||||
//
|
||||
// 以「回款计划」为先导条件:每次「新建回款」追加一条记录,构成回款进度列表(一笔笔实际到账);
|
||||
// 计划表的 received_amount / status 由本表金额汇总回写(refreshPlanReceived)。
|
||||
|
||||
// RecordList GET /backend/crm/payback/record/list
|
||||
// 回款进度:按合同 / 计划返回一笔笔实际回款记录,并给出计划总额与已回款汇总。
|
||||
func (c *BackendCrmPaybackController) RecordList() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
contractID, _ := c.GetUint64("contract_id")
|
||||
paybackID, _ := c.GetUint64("payback_id")
|
||||
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
if contractID > 0 {
|
||||
cond = cond.And("contract_id", contractID)
|
||||
}
|
||||
if paybackID > 0 {
|
||||
cond = cond.And("payback_id", paybackID)
|
||||
}
|
||||
list := make([]models.TenantCrmPaybackRecord, 0)
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||
SetCond(cond).OrderBy("-received_date", "-id").All(&list)
|
||||
|
||||
// 汇总:回款计划的计划总额与已回款;带 contract_id 时只统计该合同,否则统计全部
|
||||
// (已回款由回款记录汇总回写,兼容历史计划)
|
||||
var totalPlanned, totalReceived float64
|
||||
planCond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
if contractID > 0 {
|
||||
planCond = planCond.And("contract_id", contractID)
|
||||
}
|
||||
var plans []models.TenantCrmPayback
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).SetCond(planCond).All(&plans)
|
||||
for _, p := range plans {
|
||||
totalPlanned = round2(totalPlanned + p.TotalAmount)
|
||||
totalReceived = round2(totalReceived + p.ReceivedAmount)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": len(list),
|
||||
"total_planned": totalPlanned, "total_received": totalReceived,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRecord POST /backend/crm/payback/record
|
||||
// 新建回款:以回款计划为先导,追加一条实际回款记录,并回写计划的已回款金额与状态。
|
||||
func (c *BackendCrmPaybackController) CreateRecord() {
|
||||
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 struct {
|
||||
PaybackID uint64 `json:"payback_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
ReceivedDate string `json:"received_date"`
|
||||
PayMethod int8 `json:"pay_method"`
|
||||
ReceiptURL string `json:"receipt_url"`
|
||||
ReceiptName string `json:"receipt_name"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.PaybackID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "请先选择回款计划")
|
||||
return
|
||||
}
|
||||
if round2(p.Amount) <= 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "请填写本次回款金额")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
// 回款计划必须存在且属于当前租户(回款进度以计划为先导条件)
|
||||
var plan models.TenantCrmPayback
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||
Filter("id", p.PaybackID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&plan); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "回款计划不存在")
|
||||
return
|
||||
}
|
||||
payMethod := p.PayMethod
|
||||
if payMethod <= 0 {
|
||||
payMethod = plan.PayMethod
|
||||
}
|
||||
var contractID *uint64
|
||||
if plan.ContractID != nil {
|
||||
cid := *plan.ContractID
|
||||
contractID = &cid
|
||||
}
|
||||
now := time.Now()
|
||||
row := models.TenantCrmPaybackRecord{
|
||||
TenantID: tenantID,
|
||||
ContractID: contractID,
|
||||
PaybackID: plan.ID,
|
||||
Amount: round2(p.Amount),
|
||||
ReceivedDate: strings.TrimSpace(p.ReceivedDate),
|
||||
PayMethod: payMethod,
|
||||
ReceiptURL: strings.TrimSpace(p.ReceiptURL),
|
||||
ReceiptName: strings.TrimSpace(p.ReceiptName),
|
||||
Remark: strings.TrimSpace(p.Remark),
|
||||
OwnerUserID: plan.OwnerUserID,
|
||||
OwnerUserName: plan.OwnerUserName,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
id, ierr := models.Orm.Insert(&row)
|
||||
if ierr != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "保存失败: "+ierr.Error())
|
||||
return
|
||||
}
|
||||
refreshPlanReceived(tenantID, plan.ID)
|
||||
if contractID != nil {
|
||||
crmWriteLog(tenantID, 3, *contractID, "payback",
|
||||
fmt.Sprintf("新建回款:%s 元(%s)", formatAmount(row.Amount), row.ReceivedDate), claims)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// DeleteRecord DELETE /backend/crm/payback/record/:id
|
||||
// 删除一条回款记录(软删除),并回写计划的已回款金额与状态。
|
||||
func (c *BackendCrmPaybackController) DeleteRecord() {
|
||||
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.TenantCrmPaybackRecord
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&row); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "回款记录不存在")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Update(orm.Params{"delete_time": now, "update_time": now}); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
refreshPlanReceived(tenantID, row.PaybackID)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// refreshPlanReceived 按回款记录重算计划的已回款金额,并同步计划状态(1进行中 / 2已完成)。
|
||||
func refreshPlanReceived(tenantID string, paybackID uint64) {
|
||||
if paybackID == 0 {
|
||||
return
|
||||
}
|
||||
var rows []orm.Params
|
||||
sql := "SELECT IFNULL(SUM(amount),0) AS received FROM " +
|
||||
new(models.TenantCrmPaybackRecord).TableName() +
|
||||
" WHERE tenant_id = ? AND payback_id = ? AND delete_time IS NULL"
|
||||
if _, err := models.Orm.Raw(sql, tenantID, paybackID).Values(&rows); err != nil || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
received := round2(toFloat64(rows[0]["received"]))
|
||||
var plan models.TenantCrmPayback
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||
Filter("id", paybackID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&plan); err != nil {
|
||||
return
|
||||
}
|
||||
status := int8(1)
|
||||
if plan.TotalAmount > 0 && received+0.005 >= plan.TotalAmount {
|
||||
status = 2
|
||||
}
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||
Filter("id", paybackID).Filter("tenant_id", tenantID).
|
||||
Update(orm.Params{"received_amount": received, "status": status, "update_time": time.Now()})
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ func Init(_ string) {
|
||||
new(TenantCrmOperateLog),
|
||||
new(TenantCrmContract),
|
||||
new(TenantCrmPayback),
|
||||
new(TenantCrmPaybackRecord),
|
||||
new(ErpAccountSet),
|
||||
new(ErpNormalSetting),
|
||||
new(ErpCompanyContact),
|
||||
@@ -151,6 +152,7 @@ func Init(_ string) {
|
||||
EnsureCrmContractTechDateColumn()
|
||||
EnsureCrmProjectProductsColumn()
|
||||
EnsureCrmPaybackTable()
|
||||
EnsureCrmPaybackRecordTable()
|
||||
EnsureCrmProductDevColumns()
|
||||
}
|
||||
|
||||
@@ -193,6 +195,37 @@ func EnsureCrmPaybackTable() {
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCrmPaybackRecordTable 回款记录表建表(CREATE TABLE IF NOT EXISTS,可重复执行)。
|
||||
// 回款记录 = 回款进度流水:每次「新建回款」追加一条,以回款计划(payback_id)为先导条件。
|
||||
func EnsureCrmPaybackRecordTable() {
|
||||
sql := `CREATE TABLE IF NOT EXISTS yz_backend_crm_payback_record (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
|
||||
contract_id bigint(20) DEFAULT NULL COMMENT '关联合同ID(yz_backend_crm_contract.id)',
|
||||
payback_id bigint(20) NOT NULL DEFAULT '0' COMMENT '关联回款计划ID(yz_backend_crm_payback.id)',
|
||||
amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '本次回款金额',
|
||||
received_date varchar(20) NOT NULL DEFAULT '' COMMENT '回款日期 YYYY-MM-DD',
|
||||
pay_method tinyint(4) NOT NULL DEFAULT '1' COMMENT '回款方式:1对公/2网银/3现金/4支票/5支付宝/6微信/7其他',
|
||||
receipt_url varchar(512) DEFAULT NULL COMMENT '流水回执文件地址',
|
||||
receipt_name varchar(255) DEFAULT NULL COMMENT '流水回执文件名',
|
||||
remark text COMMENT '备注',
|
||||
owner_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
|
||||
owner_user_name varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
|
||||
create_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_tenant_contract (tenant_id,contract_id),
|
||||
KEY idx_payback (tenant_id,payback_id),
|
||||
KEY idx_delete_time (delete_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM回款记录表(回款进度流水)'`
|
||||
if _, err := Orm.Raw(sql).Exec(); err != nil {
|
||||
// 表已存在或执行失败时忽略
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCrmContractOurRoleColumn 补齐合同表的我方角色字段(存量表已建时新增;
|
||||
// 旧版「合同性质 contract_nature」字段弃用但保留不动,表不存在或列已存在时忽略错误)。
|
||||
func EnsureCrmContractOurRoleColumn() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmPaybackRecord 回款记录(回款进度流水): yz_backend_crm_payback_record
|
||||
//
|
||||
// 说明:
|
||||
// - 以「回款计划」为先导:payback_id 关联 yz_backend_crm_payback.id;
|
||||
// contract_id 冗余存储,便于按合同直接查询进度列表;
|
||||
// - 每次「新建回款」追加一条记录,构成回款进度(一笔笔实际到账),
|
||||
// 与计划内的 items(计划分期)区分开:前者是实际发生,后者是计划安排;
|
||||
// - 计划表 received_amount 由本表记录金额汇总回写(refreshPlanReceived)。
|
||||
type TenantCrmPaybackRecord struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
ContractID *uint64 `orm:"column(contract_id);null" json:"contract_id"` // 关联合同ID
|
||||
PaybackID uint64 `orm:"column(payback_id)" json:"payback_id"` // 关联回款计划ID
|
||||
Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"` // 本次回款金额
|
||||
ReceivedDate string `orm:"column(received_date);size(20)" json:"received_date"` // 回款日期 YYYY-MM-DD
|
||||
PayMethod int8 `orm:"column(pay_method);default(1)" json:"pay_method"` // 回款方式:1对公/2网银/3现金/4支票/5支付宝/6微信/7其他
|
||||
ReceiptURL string `orm:"column(receipt_url);size(512);null" json:"receipt_url"` // 流水回执文件地址
|
||||
ReceiptName string `orm:"column(receipt_name);size(255);null" json:"receipt_name"` // 流水回执文件名
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"` // 备注
|
||||
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
|
||||
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
|
||||
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *TenantCrmPaybackRecord) TableName() string {
|
||||
return "yz_backend_crm_payback_record"
|
||||
}
|
||||
@@ -429,6 +429,10 @@ func registerOrganizationRoutes(module string) {
|
||||
// CRM回款管理(针对于合同:回款计划创建 + 回款进度登记)
|
||||
beego.Router("/backend/crm/payback/list", &controllers.BackendCrmPaybackController{}, "get:List")
|
||||
beego.Router("/backend/crm/payback/progress/list", &controllers.BackendCrmPaybackController{}, "get:ProgressList")
|
||||
// 回款记录(回款进度流水):以回款计划为先导,逐笔登记实际回款
|
||||
beego.Router("/backend/crm/payback/record/list", &controllers.BackendCrmPaybackController{}, "get:RecordList")
|
||||
beego.Router("/backend/crm/payback/record", &controllers.BackendCrmPaybackController{}, "post:CreateRecord")
|
||||
beego.Router("/backend/crm/payback/record/:id", &controllers.BackendCrmPaybackController{}, "delete:DeleteRecord")
|
||||
beego.Router("/backend/crm/payback", &controllers.BackendCrmPaybackController{}, "post:Create")
|
||||
beego.Router("/backend/crm/payback/:id", &controllers.BackendCrmPaybackController{}, "get:Detail;put:Update;delete:Delete")
|
||||
beego.Router("/backend/crm/payback/:id/receive", &controllers.BackendCrmPaybackController{}, "post:Receive")
|
||||
|
||||
Reference in New Issue
Block a user