653 lines
22 KiB
Go
653 lines
22 KiB
Go
package controllers
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/models"
|
||
"server/pkg/jwtutil"
|
||
approvalEngine "server/services/approval"
|
||
erp "server/services/erp"
|
||
|
||
"github.com/beego/beego/v2/client/orm"
|
||
)
|
||
|
||
// ERP 单据核心逻辑(两端共用)。
|
||
//
|
||
// 按项目的接口隔离约定,/backend/erp/document* 与 /api/erp/document* 必须各有独立的
|
||
// handler 与路由,但单据的校验、明细装配、落库事务与审批提交口径只能有一份实现:
|
||
// 这里就是那一份。两端 handler 只负责「解析自己的令牌 → 组装 erpActor → 调核心 →
|
||
// 按各自的响应信封输出」,业务规则不在两端各写一遍,避免移动端口径慢慢跑偏。
|
||
//
|
||
// 返回约定沿用 reimburse*Core:最后一个 string 为空即成功,非空时给出处置状态码与文案,
|
||
// 由调用方原样透出,保证抽取前后接口行为完全一致。
|
||
|
||
// erpActor ERP 操作人:由各自端的 JWT 解析而来,核心层只认这个结构,不认 beego 上下文
|
||
type erpActor struct {
|
||
Tid string // 租户ID字符串口径(ERP 表的 tenant_id 是 VARCHAR)
|
||
TidNum uint64
|
||
UID uint64
|
||
Name string
|
||
}
|
||
|
||
func erpNewActor(claims *jwtutil.Claims) erpActor {
|
||
return erpActor{
|
||
Tid: fmt.Sprintf("%d", claims.TenantId),
|
||
TidNum: uint64(claims.TenantId),
|
||
UID: uint64(claims.UserID),
|
||
Name: resolveUserName(claims),
|
||
}
|
||
}
|
||
|
||
// erpDocFilter 单据列表筛选条件(与 Web 端查询参数一一对应)
|
||
type erpDocFilter struct {
|
||
DocType string
|
||
DocTypes string // 逗号分隔的多类型
|
||
Status string
|
||
Keyword string
|
||
PartnerID uint64
|
||
WarehouseID uint64
|
||
SourceDocID uint64
|
||
DateFrom string
|
||
DateTo string
|
||
}
|
||
|
||
// erpDocListCore 单据分页查询
|
||
func erpDocListCore(a erpActor, f erpDocFilter, page, pageSize int) ([]models.ErpDocument, int64, int, string) {
|
||
if f.DocType != "" && !erpDocTypes[f.DocType] {
|
||
return nil, 0, 400, "未知的单据类型"
|
||
}
|
||
qs := models.Orm.QueryTable(new(models.ErpDocument)).
|
||
Filter("tenant_id", a.Tid).Filter("delete_time__isnull", true)
|
||
if f.DocType != "" {
|
||
qs = qs.Filter("doc_type", f.DocType)
|
||
}
|
||
if types := strings.TrimSpace(f.DocTypes); types != "" {
|
||
list := make([]string, 0, 4)
|
||
for _, t := range strings.Split(types, ",") {
|
||
if erpDocTypes[strings.TrimSpace(t)] {
|
||
list = append(list, strings.TrimSpace(t))
|
||
}
|
||
}
|
||
if len(list) > 0 {
|
||
qs = qs.Filter("doc_type__in", list)
|
||
}
|
||
}
|
||
if st := strings.TrimSpace(f.Status); st != "" {
|
||
qs = qs.Filter("status", st)
|
||
}
|
||
if kw := strings.TrimSpace(f.Keyword); kw != "" {
|
||
cond := orm.NewCondition().
|
||
Or("doc_no__contains", kw).
|
||
Or("partner_name__contains", kw).
|
||
Or("remark__contains", kw)
|
||
qs = qs.SetCond(qs.GetCond().AndCond(cond))
|
||
}
|
||
if f.PartnerID > 0 {
|
||
qs = qs.Filter("partner_id", f.PartnerID)
|
||
}
|
||
if f.WarehouseID > 0 {
|
||
qs = qs.Filter("warehouse_id", f.WarehouseID)
|
||
}
|
||
if f.SourceDocID > 0 {
|
||
qs = qs.Filter("source_doc_id", f.SourceDocID)
|
||
}
|
||
for _, d := range []struct {
|
||
raw string
|
||
cond string
|
||
}{{f.DateFrom, "biz_date__gte"}, {f.DateTo, "biz_date__lte"}} {
|
||
if strings.TrimSpace(d.raw) == "" {
|
||
continue
|
||
}
|
||
if t, e := time.ParseInLocation("2006-01-02", strings.TrimSpace(d.raw), time.Local); e == nil {
|
||
qs = qs.Filter(d.cond, t)
|
||
}
|
||
}
|
||
|
||
total, _ := qs.Count()
|
||
var list []models.ErpDocument
|
||
if _, err := qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&list); err != nil {
|
||
return nil, 0, 500, "查询失败: " + err.Error()
|
||
}
|
||
erpFillDocNames(list)
|
||
for i := range list {
|
||
list[i].StatusLabel = models.ErpDocStatusLabel(list[i].Status)
|
||
}
|
||
return list, total, 200, ""
|
||
}
|
||
|
||
// erpDocDetailCore 单据详情(含明细、库存快照与审批实例摘要)
|
||
func erpDocDetailCore(a erpActor, id uint64) (*models.ErpDocument, int, string) {
|
||
if id == 0 {
|
||
return nil, 400, "无效的ID"
|
||
}
|
||
doc, err := erpLoadDoc(id, a.Tid)
|
||
if err != nil {
|
||
return nil, 404, "单据未找到"
|
||
}
|
||
doc.Items = erpDocItems(models.Orm, id)
|
||
erpFillItemStock(a.Tid, doc.Items, doc.WarehouseID)
|
||
single := []models.ErpDocument{*doc}
|
||
erpFillDocNames(single)
|
||
single[0].StatusLabel = models.ErpDocStatusLabel(single[0].Status)
|
||
if doc.InstanceID > 0 {
|
||
single[0].Instance = approvalEngine.InstanceBrief(doc.InstanceID)
|
||
}
|
||
return &single[0], 200, ""
|
||
}
|
||
|
||
// erpValidateDocHead 校验表头:仓库归属、往来单位匹配、调拨/盘点的仓库约束
|
||
func erpValidateDocHead(tid string, p *erpDocPayload) string {
|
||
needWh := models.ErpDocAffectsStock(p.DocType)
|
||
if needWh && p.WarehouseID == 0 && p.DocType != models.ErpDocStockTake {
|
||
return "请选择仓库"
|
||
}
|
||
if p.WarehouseID > 0 && !erpWarehouseOwned(tid, p.WarehouseID) {
|
||
return "仓库不存在或不属于当前租户"
|
||
}
|
||
if p.DocType == models.ErpDocTransfer {
|
||
if p.ToWarehouseID == 0 {
|
||
return "请选择调入仓库"
|
||
}
|
||
if p.ToWarehouseID == p.WarehouseID {
|
||
return "调入仓库不能与调出仓库相同"
|
||
}
|
||
if !erpWarehouseOwned(tid, p.ToWarehouseID) {
|
||
return "调入仓库不存在或不属于当前租户"
|
||
}
|
||
}
|
||
if pt := docTypePartner(p.DocType); pt != "" {
|
||
if p.PartnerID == 0 {
|
||
return "请选择" + erpPartnerLabel(pt)
|
||
}
|
||
}
|
||
if p.SourceDocID > 0 {
|
||
src, e := erpLoadDoc(p.SourceDocID, tid)
|
||
if e != nil {
|
||
return "来源单据不存在"
|
||
}
|
||
wants := docTypeSources(p.DocType)
|
||
if len(wants) > 0 {
|
||
allowed := false
|
||
labels := make([]string, 0, len(wants))
|
||
for _, w := range wants {
|
||
labels = append(labels, docTypeLabelOf(w))
|
||
if src.DocType == w {
|
||
allowed = true
|
||
}
|
||
}
|
||
if !allowed {
|
||
return "来源单据类型应为" + strings.Join(labels, "或")
|
||
}
|
||
if src.Status != models.ErpDocStatusApproved {
|
||
return "来源单据「" + src.DocNo + "」尚未生效,不能引用"
|
||
}
|
||
}
|
||
}
|
||
if p.DiscountAmount < 0 {
|
||
return "优惠金额不能为负"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// refItemIDs 收集明细行引用的来源单据行ID
|
||
func refItemIDs(raw []erpDocItemPayload) []uint64 {
|
||
out := make([]uint64, 0, len(raw))
|
||
for _, r := range raw {
|
||
if r.RefItemID > 0 {
|
||
out = append(out, r.RefItemID)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// erpBuildItems 明细行规范化:补商品快照/单价(价格体系)、算金额、校验限价与批次
|
||
func erpBuildItems(tid string, doc *models.ErpDocument, raw []erpDocItemPayload) ([]models.ErpDocumentItem, string) {
|
||
ids := make([]uint64, 0, len(raw))
|
||
for _, r := range raw {
|
||
if r.ProductID > 0 {
|
||
ids = append(ids, r.ProductID)
|
||
}
|
||
}
|
||
pmap := erpProductMap(tid, ids)
|
||
// 来源订单明细行归属:草稿覆盖保存会重建明细(行 ID 变化),前端若仍拿着旧行 ID,
|
||
// 不在开单时就拦下,脏引用要拖到审核生效才报错,移动端更难定位。
|
||
refOwner := map[uint64]uint64{}
|
||
if refIDs := refItemIDs(raw); len(refIDs) > 0 {
|
||
var refs []models.ErpDocumentItem
|
||
_, _ = models.Orm.QueryTable(new(models.ErpDocumentItem)).
|
||
Filter("id__in", refIDs).Filter("tenant_id", tid).All(&refs, "id", "doc_id")
|
||
for _, r := range refs {
|
||
refOwner[r.ID] = r.DocID
|
||
}
|
||
}
|
||
settings := erp.GetSettings(tid)
|
||
out := make([]models.ErpDocumentItem, 0, len(raw))
|
||
for i, r := range raw {
|
||
p, okm := pmap[r.ProductID]
|
||
if !okm {
|
||
return nil, fmt.Sprintf("第%d行商品不存在", i+1)
|
||
}
|
||
if p.IsDraft == 1 || p.Status != 1 {
|
||
return nil, fmt.Sprintf("商品「%s」未启用,不能开单", p.ProductName)
|
||
}
|
||
if r.Quantity <= 0 {
|
||
return nil, fmt.Sprintf("商品「%s」数量必须大于0", p.ProductName)
|
||
}
|
||
unitPrice := r.UnitPrice
|
||
if unitPrice <= 0 {
|
||
unitPrice = erpPickPrice(p, doc.DocType, settings.SalesPriceType)
|
||
}
|
||
// 限价控制:低于最低售价直接拦截(除管理员放行的场景,这里不做例外)
|
||
if strings.HasPrefix(doc.DocType, "sales") && p.MinPrice > 0 && unitPrice < p.MinPrice-0.005 {
|
||
return nil, fmt.Sprintf("商品「%s」售价 %.2f 低于最低售价 %.2f", p.ProductName, unitPrice, p.MinPrice)
|
||
}
|
||
if doc.DocType == models.ErpDocPurchaseOrder || doc.DocType == models.ErpDocPurchaseIn {
|
||
if p.EnableBatch == 1 && strings.TrimSpace(r.BatchNo) == "" && doc.DocType == models.ErpDocPurchaseIn {
|
||
return nil, fmt.Sprintf("商品「%s」启用批次管理,请填写批次号", p.ProductName)
|
||
}
|
||
}
|
||
unit := strings.TrimSpace(r.Unit)
|
||
if unit == "" {
|
||
unit = p.BaseUnit
|
||
}
|
||
amount := r.Amount
|
||
if amount <= 0 {
|
||
amount = round2f(r.Quantity * unitPrice)
|
||
}
|
||
wh := r.WarehouseID
|
||
if wh > 0 && !erpWarehouseOwned(tid, wh) {
|
||
return nil, fmt.Sprintf("第%d行仓库无效", i+1)
|
||
}
|
||
if r.RefItemID > 0 && refOwner[r.RefItemID] != doc.SourceDocID {
|
||
return nil, fmt.Sprintf("第%d行关联的来源单据明细已失效,请重新选择来源单据", i+1)
|
||
}
|
||
it := models.ErpDocumentItem{
|
||
ProductID: p.ID, ProductName: p.ProductName, Spec: p.Spec, Unit: unit,
|
||
WarehouseID: wh, BatchNo: strings.TrimSpace(r.BatchNo),
|
||
Quantity: round4f(r.Quantity), BookQty: round4f(r.BookQty),
|
||
RefItemID: r.RefItemID, UnitPrice: unitPrice, Amount: round2f(amount),
|
||
Remark: r.Remark, SortOrder: r.SortOrder,
|
||
}
|
||
if it.SortOrder == 0 {
|
||
it.SortOrder = i + 1
|
||
}
|
||
if r.ProductionDate != "" {
|
||
if t, e := time.ParseInLocation("2006-01-02", r.ProductionDate, time.Local); e == nil {
|
||
it.ProductionDate = &t
|
||
if p.ShelfLifeDays > 0 {
|
||
ed := t.AddDate(0, 0, p.ShelfLifeDays)
|
||
it.ExpireDate = &ed
|
||
}
|
||
}
|
||
}
|
||
if r.ExpireDate != "" {
|
||
if t, e := time.ParseInLocation("2006-01-02", r.ExpireDate, time.Local); e == nil {
|
||
it.ExpireDate = &t
|
||
}
|
||
}
|
||
// 盘点行未带账面数时按当前结存补齐
|
||
if doc.DocType == models.ErpDocStockTake && it.BookQty == 0 && it.WarehouseID == 0 {
|
||
it.BookQty = erpStockQty(tid, p.ID, doc.WarehouseID, it.BatchNo)
|
||
if it.UnitPrice == 0 {
|
||
it.UnitPrice = erpStockCost(tid, p.ID, doc.WarehouseID, it.BatchNo)
|
||
}
|
||
}
|
||
if msg := erpLinePreCheck(tid, doc, settings, &it); msg != "" {
|
||
return nil, msg
|
||
}
|
||
out = append(out, it)
|
||
}
|
||
return out, ""
|
||
}
|
||
|
||
// erpDocSaveCore 新建或覆盖保存草稿(payload 带 id 即为编辑)。已生效/审批中的单据不允许改动。
|
||
func erpDocSaveCore(a erpActor, p erpDocPayload) (uint64, string, int, string) {
|
||
tid := a.Tid
|
||
if !erpDocTypes[p.DocType] {
|
||
return 0, "", 400, "未知的单据类型"
|
||
}
|
||
if len(p.Items) == 0 {
|
||
return 0, "", 400, "请至少添加一行明细"
|
||
}
|
||
if errMsg := erpValidateDocHead(tid, &p); errMsg != "" {
|
||
return 0, "", 400, errMsg
|
||
}
|
||
|
||
doc := &models.ErpDocument{TenantID: tid}
|
||
if p.ID > 0 {
|
||
exist, e := erpLoadDoc(p.ID, tid)
|
||
if e != nil {
|
||
return 0, "", 404, "单据未找到"
|
||
}
|
||
if exist.Status == models.ErpDocStatusApproved || exist.Status == models.ErpDocStatusPending {
|
||
return 0, "", 400, "审批中或已生效的单据不可编辑,请先撤回或作废"
|
||
}
|
||
if exist.Status == models.ErpDocStatusCancelled {
|
||
return 0, "", 400, "已作废单据不可编辑"
|
||
}
|
||
doc = exist
|
||
}
|
||
if doc.DocNo == "" {
|
||
no, e := erp.GenerateDocNo(nil, tid, p.DocType)
|
||
if e != nil {
|
||
return 0, "", 500, e.Error()
|
||
}
|
||
doc.DocNo = no
|
||
}
|
||
bizDate := time.Now()
|
||
if p.BizDate != "" {
|
||
if t, e := time.ParseInLocation("2006-01-02", p.BizDate, time.Local); e == nil {
|
||
bizDate = t
|
||
}
|
||
}
|
||
doc.DocType = p.DocType
|
||
doc.BizDate = bizDate
|
||
doc.WarehouseID = p.WarehouseID
|
||
doc.ToWarehouseID = p.ToWarehouseID
|
||
doc.SourceDocID = p.SourceDocID
|
||
doc.DiscountAmount = p.DiscountAmount
|
||
doc.Remark = p.Remark
|
||
doc.Status = models.ErpDocStatusDraft
|
||
if pt := docTypePartner(p.DocType); pt != "" {
|
||
doc.PartnerType = pt
|
||
doc.PartnerID = p.PartnerID
|
||
doc.PartnerName = erpPartnerName(pt, p.PartnerID)
|
||
if doc.PartnerName == "" {
|
||
return 0, "", 400, erpPartnerLabel(pt) + "未选择或不存在"
|
||
}
|
||
} else {
|
||
doc.PartnerType, doc.PartnerID, doc.PartnerName = "", 0, ""
|
||
}
|
||
if doc.CreateUserID == "" {
|
||
doc.CreateUserID = fmt.Sprintf("%d", a.UID)
|
||
doc.CreateUserName = a.Name
|
||
}
|
||
|
||
items, errMsg := erpBuildItems(tid, doc, p.Items)
|
||
if errMsg != "" {
|
||
return 0, "", 400, errMsg
|
||
}
|
||
doc.TotalQty = 0
|
||
doc.TotalAmount = 0
|
||
for _, it := range items {
|
||
doc.TotalQty += it.Quantity
|
||
doc.TotalAmount += it.Amount
|
||
}
|
||
doc.TotalQty = round4f(doc.TotalQty)
|
||
doc.TotalAmount = round2f(doc.TotalAmount)
|
||
doc.ActualAmount = round2f(doc.TotalAmount - doc.DiscountAmount)
|
||
if doc.ActualAmount < 0 {
|
||
return 0, "", 400, "优惠金额不能大于单据金额"
|
||
}
|
||
|
||
o := models.Orm
|
||
tx, err := o.Begin()
|
||
if err != nil {
|
||
return 0, "", 500, "保存失败"
|
||
}
|
||
rollback := true
|
||
defer func() { if rollback { _ = tx.Rollback() } }()
|
||
|
||
now := time.Now()
|
||
doc.UpdateTime = now
|
||
if doc.ID == 0 {
|
||
doc.CreateTime = now
|
||
id, e := tx.Insert(doc)
|
||
if e != nil {
|
||
return 0, "", 500, "保存失败: " + e.Error()
|
||
}
|
||
doc.ID = uint64(id)
|
||
} else {
|
||
if _, e := tx.QueryTable(new(models.ErpDocument)).Filter("id", doc.ID).
|
||
Update(orm.Params{
|
||
"doc_type": doc.DocType, "biz_date": doc.BizDate, "warehouse_id": doc.WarehouseID,
|
||
"to_warehouse_id": doc.ToWarehouseID, "partner_type": doc.PartnerType,
|
||
"partner_id": doc.PartnerID, "partner_name": doc.PartnerName,
|
||
"source_doc_id": doc.SourceDocID, "total_qty": doc.TotalQty,
|
||
"total_amount": doc.TotalAmount, "discount_amount": doc.DiscountAmount,
|
||
"actual_amount": doc.ActualAmount, "status": models.ErpDocStatusDraft,
|
||
"instance_id": 0, "remark": doc.Remark, "update_time": now,
|
||
}); e != nil {
|
||
return 0, "", 500, "保存失败: " + e.Error()
|
||
}
|
||
if _, e := tx.QueryTable(new(models.ErpDocumentItem)).Filter("doc_id", doc.ID).Delete(); e != nil {
|
||
return 0, "", 500, "保存失败: " + e.Error()
|
||
}
|
||
}
|
||
for i := range items {
|
||
items[i].DocID = doc.ID
|
||
items[i].TenantID = tid
|
||
items[i].ID = 0
|
||
if _, e := tx.Insert(&items[i]); e != nil {
|
||
return 0, "", 500, "保存明细失败: " + e.Error()
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return 0, "", 500, "保存失败: " + err.Error()
|
||
}
|
||
rollback = false
|
||
return doc.ID, doc.DocNo, 200, ""
|
||
}
|
||
|
||
// erpDocDeleteCore 删除单据(软删主表、硬删明细),仅草稿/驳回可删
|
||
func erpDocDeleteCore(a erpActor, id uint64) (int, string) {
|
||
doc, errMsg := erpLoadEditable(id, a.Tid, []int8{models.ErpDocStatusDraft, models.ErpDocStatusRejected})
|
||
if errMsg != "" {
|
||
return 400, errMsg
|
||
}
|
||
now := time.Now()
|
||
tx, err := models.Orm.Begin()
|
||
if err != nil {
|
||
return 500, "删除失败"
|
||
}
|
||
rollback := true
|
||
defer func() { if rollback { _ = tx.Rollback() } }()
|
||
if _, err = tx.QueryTable(new(models.ErpDocument)).Filter("id", doc.ID).
|
||
Update(orm.Params{"delete_time": now, "update_time": now}); err != nil {
|
||
return 500, "删除失败: " + err.Error()
|
||
}
|
||
// 明细随主表硬删:主表只软删,历史仍可追溯;明细无独立查询价值
|
||
if _, err = tx.QueryTable(new(models.ErpDocumentItem)).Filter("doc_id", doc.ID).Delete(); err != nil {
|
||
return 500, "删除失败: " + err.Error()
|
||
}
|
||
if err = tx.Commit(); err != nil {
|
||
return 500, "删除失败"
|
||
}
|
||
rollback = false
|
||
return 200, ""
|
||
}
|
||
|
||
// erpDocCancelCore 作废:草稿/驳回/审批中均可作废;已生效单据不做反向单据,直接拒绝,
|
||
// 保证「已生效=流水已入账」这一不可篡改前提。
|
||
func erpDocCancelCore(a erpActor, id uint64) (int, string) {
|
||
doc, errMsg := erpLoadEditable(id, a.Tid, []int8{
|
||
models.ErpDocStatusDraft, models.ErpDocStatusRejected, models.ErpDocStatusPending,
|
||
})
|
||
if errMsg != "" {
|
||
return 400, errMsg
|
||
}
|
||
now := time.Now()
|
||
params := orm.Params{"status": models.ErpDocStatusCancelled, "instance_id": 0, "update_time": now}
|
||
if _, err := models.Orm.QueryTable(new(models.ErpDocument)).Filter("id", doc.ID).Update(params); err != nil {
|
||
return 500, "作废失败: " + err.Error()
|
||
}
|
||
if doc.InstanceID > 0 {
|
||
approvalEngine.CancelInstance(doc.InstanceID, "单据已作废")
|
||
}
|
||
return 200, ""
|
||
}
|
||
|
||
// erpDocApproveCore 直接审核生效(未走审批流时使用)。库存/往来账效果在同一事务内落地。
|
||
func erpDocApproveCore(a erpActor, id uint64) (int, string) {
|
||
if id == 0 {
|
||
return 400, "无效的ID"
|
||
}
|
||
if err := erp.ApproveDocument(id, a.Tid, fmt.Sprintf("%d", a.UID), a.Name); err != nil {
|
||
return 400, err.Error()
|
||
}
|
||
return 200, ""
|
||
}
|
||
|
||
// erpDocSubmitCore 提交审批:flowID 为 0 时按单据类型自动选流。
|
||
// 流程未配置人工节点时引擎会直接结束实例,此时同步单据终态。
|
||
func erpDocSubmitCore(a erpActor, id, flowID uint64) (uint64, int, string) {
|
||
if id == 0 {
|
||
return 0, 400, "无效的ID"
|
||
}
|
||
doc, errMsg := erpLoadEditable(id, a.Tid, []int8{models.ErpDocStatusDraft, models.ErpDocStatusRejected})
|
||
if errMsg != "" {
|
||
return 0, 400, errMsg
|
||
}
|
||
if len(erpDocItems(models.Orm, doc.ID)) == 0 {
|
||
return 0, 400, "单据没有明细行,无法提交"
|
||
}
|
||
bizType := models.ErpDocApprovalBizType(doc.DocType)
|
||
deptID := erpUserOrgID(a.TidNum, a.UID)
|
||
if flowID == 0 {
|
||
flowID = erpPickFlowID(a.TidNum, deptID, bizType)
|
||
}
|
||
if flowID == 0 {
|
||
return 0, 400, "该单据类型尚未配置审批流程,可直接审核或到「审批流配置」中新增"
|
||
}
|
||
instID, err := approvalEngine.Submit(approvalEngine.SubmitInput{
|
||
Scope: models.ApprovalScopeTenant,
|
||
Tid: a.TidNum,
|
||
FlowID: flowID,
|
||
BizType: bizType,
|
||
BizID: doc.ID,
|
||
BizTitle: fmt.Sprintf("[%s] %s %s", docTypeLabelOf(doc.DocType), doc.DocNo, doc.PartnerName),
|
||
SubmitUID: a.UID,
|
||
SubmitName: a.Name,
|
||
DeptID: deptID,
|
||
FormData: map[string]interface{}{
|
||
"doc_type": doc.DocType,
|
||
"doc_no": doc.DocNo,
|
||
"total_amount": doc.TotalAmount,
|
||
"actual_amount": doc.ActualAmount,
|
||
"total_qty": doc.TotalQty,
|
||
"warehouse_id": doc.WarehouseID,
|
||
"partner_id": doc.PartnerID,
|
||
"partner_type": doc.PartnerType,
|
||
},
|
||
})
|
||
if err != nil {
|
||
return 0, 400, err.Error()
|
||
}
|
||
now := time.Now()
|
||
if _, err := models.Orm.QueryTable(new(models.ErpDocument)).Filter("id", doc.ID).
|
||
Update(orm.Params{"status": models.ErpDocStatusPending, "instance_id": instID, "update_time": now}); err != nil {
|
||
return 0, 500, "提交失败: " + err.Error()
|
||
}
|
||
approvalEngine.SyncBizResult(instID, approvalEngine.InstanceStatusOf(instID))
|
||
return instID, 200, ""
|
||
}
|
||
|
||
type erpFlowRow struct {
|
||
FlowID uint64 `json:"flow_id"`
|
||
FlowCode string `json:"flow_code"`
|
||
FlowName string `json:"flow_name"`
|
||
BizType string `json:"biz_type"`
|
||
}
|
||
|
||
// erpDocFlowsCore 该单据类型可用的审批流(先精确匹配 erp_xxx,再回落到模块级 erp)
|
||
func erpDocFlowsCore(a erpActor, docType string) (string, []erpFlowRow, int, string) {
|
||
if !erpDocTypes[docType] {
|
||
return "", nil, 400, "未知的单据类型"
|
||
}
|
||
bizType := models.ErpDocApprovalBizType(docType)
|
||
deptID := erpUserOrgID(a.TidNum, a.UID)
|
||
flows, err := approvalEngine.ListAvailableFlows(models.ApprovalScopeTenant, a.TidNum, bizType, deptID)
|
||
if err != nil {
|
||
return bizType, nil, 500, err.Error()
|
||
}
|
||
if len(flows) == 0 {
|
||
flows, _ = approvalEngine.ListAvailableFlows(models.ApprovalScopeTenant, a.TidNum, "erp", deptID)
|
||
}
|
||
out := make([]erpFlowRow, 0, len(flows))
|
||
for _, f := range flows {
|
||
out = append(out, erpFlowRow{f.FlowID, f.FlowCode, f.FlowName, f.BizType})
|
||
}
|
||
return bizType, out, 200, ""
|
||
}
|
||
|
||
// erpDocExecutableCore 取来源订单中尚可执行的明细行(入库/出库/退货按行带出,避免超收超发)
|
||
func erpDocExecutableCore(a erpActor, id uint64) (map[string]interface{}, int, string) {
|
||
doc, e := erpLoadDoc(id, a.Tid)
|
||
if e != nil {
|
||
return nil, 404, "单据未找到"
|
||
}
|
||
isReturn := doc.DocType == models.ErpDocPurchaseReturn || doc.DocType == models.ErpDocSalesReturn
|
||
items := erpDocItems(models.Orm, doc.ID)
|
||
out := make([]map[string]interface{}, 0, len(items))
|
||
for _, it := range items {
|
||
basis := it.Quantity
|
||
used := it.ExecutedQty
|
||
if isReturn {
|
||
used = erpReturnedQty(a.Tid, it.ID, 0)
|
||
}
|
||
remain := basis - used
|
||
if remain < -0.000001 {
|
||
remain = 0
|
||
}
|
||
out = append(out, map[string]interface{}{
|
||
"item_id": it.ID, "product_id": it.ProductID, "product_name": it.ProductName,
|
||
"spec": it.Spec, "unit": it.Unit, "batch_no": it.BatchNo,
|
||
"quantity": it.Quantity, "unit_price": it.UnitPrice, "amount": it.Amount,
|
||
"executed_qty": round4f(used), "remain_qty": round4f(remain),
|
||
})
|
||
}
|
||
return map[string]interface{}{"doc": doc, "items": out}, 200, ""
|
||
}
|
||
|
||
// erpTakeSheetCore 盘点表初始化:带出当前账面数量与加权成本,盘点员只改实盘数
|
||
func erpTakeSheetCore(a erpActor, warehouseID uint64) ([]map[string]interface{}, int, string) {
|
||
var stocks []models.ErpStock
|
||
_, _ = models.Orm.QueryTable(new(models.ErpStock)).
|
||
Filter("tenant_id", a.Tid).Filter("warehouse_id", warehouseID).
|
||
OrderBy("product_id", "batch_no").All(&stocks)
|
||
ids := make([]uint64, 0, len(stocks))
|
||
for _, s := range stocks {
|
||
ids = append(ids, s.ProductID)
|
||
}
|
||
pmap := erpProductMap(a.Tid, ids)
|
||
out := make([]map[string]interface{}, 0, len(stocks))
|
||
for _, s := range stocks {
|
||
p := pmap[s.ProductID]
|
||
out = append(out, map[string]interface{}{
|
||
"product_id": s.ProductID, "product_name": p.ProductName, "product_code": p.ProductCode,
|
||
"spec": p.Spec, "unit": p.BaseUnit, "batch_no": s.BatchNo,
|
||
"book_qty": s.Quantity, "cost_price": s.CostPrice,
|
||
})
|
||
}
|
||
return out, 200, ""
|
||
}
|
||
|
||
// erpDocMetaDict 开单页所需字典与默认值(两端共用同一份口径)
|
||
func erpDocMetaDict(a erpActor) map[string]interface{} {
|
||
s := erp.GetSettings(a.Tid)
|
||
types := make([]map[string]interface{}, 0, len(models.ErpDocTypeLabel))
|
||
for _, k := range []string{
|
||
models.ErpDocPurchaseOrder, models.ErpDocPurchaseIn, models.ErpDocPurchaseReturn,
|
||
models.ErpDocSalesOrder, models.ErpDocSalesOut, models.ErpDocSalesReturn,
|
||
models.ErpDocOtherIn, models.ErpDocOtherOut, models.ErpDocTransfer, models.ErpDocStockTake,
|
||
} {
|
||
types = append(types, map[string]interface{}{
|
||
"value": k, "label": models.ErpDocTypeLabel[k],
|
||
"affects_stock": models.ErpDocAffectsStock(k),
|
||
"partner_type": docTypePartner(k),
|
||
})
|
||
}
|
||
return map[string]interface{}{
|
||
"doc_types": types,
|
||
"statuses": []map[string]interface{}{
|
||
{"value": 0, "label": "草稿"}, {"value": 1, "label": "审批中"},
|
||
{"value": 2, "label": "已生效"}, {"value": 3, "label": "已驳回"}, {"value": 4, "label": "已作废"},
|
||
},
|
||
"default_warehouse_id": s.DefaultWarehouseID,
|
||
"sales_price_type": s.SalesPriceType,
|
||
"negative_stock": s.NegativeAllowed,
|
||
}
|
||
}
|