469 lines
14 KiB
Go
469 lines
14 KiB
Go
package controllers
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"strings"
|
||
|
||
"server/models"
|
||
approvalEngine "server/services/approval"
|
||
erp "server/services/erp"
|
||
)
|
||
|
||
// BackendErpDocumentController 进销存单据中心(采购/销售/库存十类单据共用一套接口)
|
||
//
|
||
// 单据生命周期:草稿(0) →[提交审批]→ 审批中(1) →[引擎回调]→ 已生效(2)/已驳回(3),
|
||
// 或草稿/驳回/审批中 →[直接审核/作废]→ 已生效(2)/已作废(4)。
|
||
// 只有「已生效」才由 services/erp.ApplyDocEffect 产生库存流水与往来账,
|
||
// 库存结存一律由流水驱动,接口层不提供任何直接改库存的入口。
|
||
//
|
||
// 本文件只做「解令牌 + 组装参数 + 输出响应」,校验与落库统一在 erp_document_core.go,
|
||
// 移动端 /api/erp/document* 走同一套核心,避免两端各写一遍业务规则。
|
||
type BackendErpDocumentController struct {
|
||
BackendErpBaseController
|
||
}
|
||
|
||
type erpDocItemPayload struct {
|
||
ID uint64 `json:"id"`
|
||
ProductID uint64 `json:"product_id"`
|
||
ProductName string `json:"product_name"`
|
||
Spec string `json:"spec"`
|
||
Unit string `json:"unit"`
|
||
WarehouseID uint64 `json:"warehouse_id"`
|
||
BatchNo string `json:"batch_no"`
|
||
Quantity float64 `json:"quantity"`
|
||
BookQty float64 `json:"book_qty"`
|
||
RefItemID uint64 `json:"ref_item_id"`
|
||
UnitPrice float64 `json:"unit_price"`
|
||
Amount float64 `json:"amount"`
|
||
ProductionDate string `json:"production_date"`
|
||
ExpireDate string `json:"expire_date"`
|
||
Remark string `json:"remark"`
|
||
SortOrder int `json:"sort_order"`
|
||
}
|
||
|
||
type erpDocPayload struct {
|
||
ID uint64 `json:"id"`
|
||
DocNo string `json:"doc_no"`
|
||
DocType string `json:"doc_type"`
|
||
BizDate string `json:"biz_date"`
|
||
WarehouseID uint64 `json:"warehouse_id"`
|
||
ToWarehouseID uint64 `json:"to_warehouse_id"`
|
||
PartnerType string `json:"partner_type"`
|
||
PartnerID uint64 `json:"partner_id"`
|
||
SourceDocID uint64 `json:"source_doc_id"`
|
||
DiscountAmount float64 `json:"discount_amount"`
|
||
Remark string `json:"remark"`
|
||
Items []erpDocItemPayload `json:"items"`
|
||
}
|
||
|
||
// docTypePartner 单据类型对应的往来单位类型(空=不需要往来单位)
|
||
func docTypePartner(docType string) string {
|
||
switch docType {
|
||
case models.ErpDocPurchaseOrder, models.ErpDocPurchaseIn, models.ErpDocPurchaseReturn:
|
||
return "supplier"
|
||
case models.ErpDocSalesOrder, models.ErpDocSalesOut, models.ErpDocSalesReturn:
|
||
return "customer"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// docTypeSources 单据允许的"来源单据"类型集合(空=不限来源)。
|
||
// 退货既可追溯到订单,也可追溯到已生效的入库/出库单,故两者都放行。
|
||
func docTypeSources(docType string) []string {
|
||
switch docType {
|
||
case models.ErpDocPurchaseIn:
|
||
return []string{models.ErpDocPurchaseOrder}
|
||
case models.ErpDocPurchaseReturn:
|
||
return []string{models.ErpDocPurchaseOrder, models.ErpDocPurchaseIn}
|
||
case models.ErpDocSalesOut:
|
||
return []string{models.ErpDocSalesOrder}
|
||
case models.ErpDocSalesReturn:
|
||
return []string{models.ErpDocSalesOrder, models.ErpDocSalesOut}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// List GET /backend/erp/document/list
|
||
func (c *BackendErpDocumentController) List() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
page, pageSize := c.pageArgs()
|
||
|
||
f := erpDocFilter{
|
||
DocType: strings.TrimSpace(c.GetString("doc_type")),
|
||
DocTypes: strings.TrimSpace(c.GetString("doc_types")),
|
||
Status: strings.TrimSpace(c.GetString("status")),
|
||
Keyword: strings.TrimSpace(c.GetString("keyword")),
|
||
PartnerID: erpUintParam(c.GetString("partner_id")),
|
||
WarehouseID: erpUintParam(c.GetString("warehouse_id")),
|
||
SourceDocID: erpUintParam(c.GetString("source_doc_id")),
|
||
DateFrom: strings.TrimSpace(c.GetString("date_from")),
|
||
DateTo: strings.TrimSpace(c.GetString("date_to")),
|
||
}
|
||
list, total, code, msg := erpDocListCore(erpNewActor(cl), f, page, pageSize)
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.listOK(list, total, page, pageSize)
|
||
}
|
||
|
||
// Detail GET /backend/erp/document/:id
|
||
func (c *BackendErpDocumentController) Detail() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
doc, code, msg := erpDocDetailCore(erpNewActor(cl), c.pathID())
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.ok(doc)
|
||
}
|
||
|
||
// Save POST /backend/erp/document
|
||
// 新建或覆盖保存草稿(payload 带 id 即为编辑)。已生效/审批中的单据不允许改动。
|
||
func (c *BackendErpDocumentController) Save() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
var p erpDocPayload
|
||
if !c.bindJSON(&p) {
|
||
return
|
||
}
|
||
id, docNo, code, msg := erpDocSaveCore(erpNewActor(cl), p)
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.ok(map[string]interface{}{"id": id, "doc_no": docNo})
|
||
}
|
||
|
||
// Delete DELETE /backend/erp/document/:id 仅草稿/驳回可删除(软删)
|
||
func (c *BackendErpDocumentController) Delete() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
code, msg := erpDocDeleteCore(erpNewActor(cl), c.pathID())
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.ok(nil)
|
||
}
|
||
|
||
// Cancel POST /backend/erp/document/:id/cancel 作废单据
|
||
func (c *BackendErpDocumentController) Cancel() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
code, msg := erpDocCancelCore(erpNewActor(cl), c.pathID())
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.ok(nil)
|
||
}
|
||
|
||
// Approve POST /backend/erp/document/:id/approve
|
||
// 直接审核生效(未走审批流时使用)。库存/往来账效果在同一事务内落地。
|
||
func (c *BackendErpDocumentController) Approve() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
code, msg := erpDocApproveCore(erpNewActor(cl), c.pathID())
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.ok(nil)
|
||
}
|
||
|
||
// Submit POST /backend/erp/document/:id/submit
|
||
func (c *BackendErpDocumentController) Submit() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
instID, code, msg := erpDocSubmitCore(erpNewActor(cl), c.pathID(),
|
||
erpUintParam(c.GetString("flow_id")))
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.ok(map[string]interface{}{"instance_id": instID})
|
||
}
|
||
|
||
// Flows GET /backend/erp/document/flows?doc_type=
|
||
// 返回该单据类型可用的审批流,前端据此决定是否显示「提交审批」。
|
||
func (c *BackendErpDocumentController) Flows() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
bizType, flows, code, msg := erpDocFlowsCore(erpNewActor(cl), strings.TrimSpace(c.GetString("doc_type")))
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.ok(map[string]interface{}{"list": flows, "biz_type": bizType})
|
||
}
|
||
|
||
// Executable GET /backend/erp/document/:id/executable
|
||
// 取来源订单中尚可执行的明细行(入库/出库/退货按行带出,避免超收超发)。
|
||
func (c *BackendErpDocumentController) Executable() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
data, code, msg := erpDocExecutableCore(erpNewActor(cl), c.pathID())
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.ok(data)
|
||
}
|
||
|
||
// TakeSheet GET /backend/erp/document/takeSheet?warehouse_id=
|
||
// 盘点表初始化:带出当前账面数量与加权成本,盘点员只改实盘数。
|
||
func (c *BackendErpDocumentController) TakeSheet() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
wid := erpUintParam(c.GetString("warehouse_id"))
|
||
if wid == 0 {
|
||
c.fail(400, 400, "请选择仓库")
|
||
return
|
||
}
|
||
list, code, msg := erpTakeSheetCore(erpNewActor(cl), wid)
|
||
if msg != "" {
|
||
c.fail(code, code, msg)
|
||
return
|
||
}
|
||
c.ok(map[string]interface{}{"list": list, "warehouse_id": wid})
|
||
}
|
||
|
||
// Meta GET /backend/erp/document/meta 前端开单页所需的字典与默认值
|
||
func (c *BackendErpDocumentController) Meta() {
|
||
cl, err := c.claims()
|
||
if c.denied(err) {
|
||
return
|
||
}
|
||
c.ok(erpDocMetaDict(erpNewActor(cl)))
|
||
}
|
||
|
||
// ---- 内部 ----
|
||
|
||
// erpLinePreCheck 开单录入时的行前校验:超订单可执行量、超原单可退量、库存不足。
|
||
// 审核生效引擎会再校验一次,这里只是让开单人当场看到问题,不用等审批完才发现。
|
||
func erpLinePreCheck(tid string, doc *models.ErpDocument, s *erp.Settings, it *models.ErpDocumentItem) string {
|
||
switch doc.DocType {
|
||
case models.ErpDocPurchaseIn, models.ErpDocSalesOut:
|
||
if it.RefItemID == 0 {
|
||
break
|
||
}
|
||
var ref models.ErpDocumentItem
|
||
if models.Orm.QueryTable(new(models.ErpDocumentItem)).Filter("id", it.RefItemID).
|
||
One(&ref, "quantity", "executed_qty") == nil {
|
||
if remain := ref.Quantity - ref.ExecutedQty; it.Quantity-remain > 0.000001 {
|
||
return fmt.Sprintf("商品「%s」超出订单可执行数量(剩余 %.4f,本次 %.4f)", it.ProductName, remain, it.Quantity)
|
||
}
|
||
}
|
||
case models.ErpDocPurchaseReturn, models.ErpDocSalesReturn:
|
||
if it.RefItemID == 0 {
|
||
break
|
||
}
|
||
var ref models.ErpDocumentItem
|
||
if models.Orm.QueryTable(new(models.ErpDocumentItem)).Filter("id", it.RefItemID).
|
||
One(&ref, "quantity") == nil {
|
||
left := ref.Quantity - erpReturnedQty(tid, it.RefItemID, doc.ID)
|
||
if it.Quantity-left > 0.000001 {
|
||
return fmt.Sprintf("商品「%s」退货数量超出原单可退数量(可退 %.4f,本次 %.4f)", it.ProductName, left, it.Quantity)
|
||
}
|
||
}
|
||
}
|
||
var outQty float64
|
||
switch doc.DocType {
|
||
case models.ErpDocSalesOut, models.ErpDocPurchaseReturn, models.ErpDocOtherOut:
|
||
outQty = it.Quantity
|
||
case models.ErpDocTransfer:
|
||
outQty = it.Quantity
|
||
}
|
||
if outQty > 0 && !s.NegativeAllowed {
|
||
wh := it.WarehouseID
|
||
if wh == 0 {
|
||
wh = doc.WarehouseID
|
||
}
|
||
if avail := erpStockQty(tid, it.ProductID, wh, it.BatchNo); avail-outQty < -0.000001 {
|
||
return fmt.Sprintf("商品「%s」库存不足:可用 %.4f,本次需要 %.4f", it.ProductName, avail, outQty)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// erpPickPrice 价格体系取价:采购单取采购价,销售类按基础设置的售价类型
|
||
func erpPickPrice(p models.ErpProduct, docType, salesPriceType string) float64 {
|
||
switch docType {
|
||
case models.ErpDocPurchaseOrder, models.ErpDocPurchaseIn, models.ErpDocOtherIn:
|
||
return p.PurchasePrice
|
||
case models.ErpDocPurchaseReturn:
|
||
return p.PurchasePrice
|
||
case models.ErpDocSalesReturn:
|
||
return p.SalePrice
|
||
case models.ErpDocSalesOrder, models.ErpDocSalesOut, models.ErpDocOtherOut:
|
||
switch salesPriceType {
|
||
case "wholesale":
|
||
if p.WholesalePrice > 0 {
|
||
return p.WholesalePrice
|
||
}
|
||
case "member":
|
||
if p.MemberPrice > 0 {
|
||
return p.MemberPrice
|
||
}
|
||
}
|
||
return p.SalePrice
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// erpLoadEditable 取当前租户单据并校验状态在允许集合内
|
||
func erpLoadEditable(id uint64, tid string, allow []int8) (*models.ErpDocument, string) {
|
||
if id == 0 {
|
||
return nil, "无效的ID"
|
||
}
|
||
doc, err := erpLoadDoc(id, tid)
|
||
if err != nil {
|
||
return nil, "单据未找到"
|
||
}
|
||
for _, s := range allow {
|
||
if doc.Status == s {
|
||
return doc, ""
|
||
}
|
||
}
|
||
return nil, "当前状态不可执行该操作"
|
||
}
|
||
|
||
func erpWarehouseOwned(tid string, id uint64) bool {
|
||
var w models.ErpWarehouse
|
||
return models.Orm.QueryTable(new(models.ErpWarehouse)).
|
||
Filter("id", id).Filter("tenant_id", tid).
|
||
Filter("delete_time__isnull", true).One(&w) == nil
|
||
}
|
||
|
||
func erpPartnerName(partnerType string, id uint64) string {
|
||
if id == 0 {
|
||
return ""
|
||
}
|
||
if partnerType == "supplier" {
|
||
var s models.TenantCrmSupplier
|
||
if err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
|
||
Filter("id", id).Filter("delete_time__isnull", true).One(&s); err == nil {
|
||
return s.SupplierName
|
||
}
|
||
return ""
|
||
}
|
||
var cu models.TenantCrmCustomer
|
||
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||
Filter("id", id).Filter("delete_time__isnull", true).One(&cu); err == nil {
|
||
return cu.CustomerName
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func erpProductMap(tid string, ids []uint64) map[uint64]models.ErpProduct {
|
||
out := map[uint64]models.ErpProduct{}
|
||
if len(ids) == 0 {
|
||
return out
|
||
}
|
||
var rows []models.ErpProduct
|
||
_, _ = models.Orm.QueryTable(new(models.ErpProduct)).
|
||
Filter("tenant_id", tid).Filter("id__in", ids).All(&rows)
|
||
for _, r := range rows {
|
||
out[r.ID] = r
|
||
}
|
||
return out
|
||
}
|
||
|
||
func erpStockQty(tid string, productID, warehouseID uint64, batch string) float64 {
|
||
var s models.ErpStock
|
||
if err := models.Orm.QueryTable(new(models.ErpStock)).
|
||
Filter("tenant_id", tid).Filter("product_id", productID).
|
||
Filter("warehouse_id", warehouseID).Filter("batch_no", batch).
|
||
One(&s, "quantity"); err == nil {
|
||
return s.Quantity
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func erpStockCost(tid string, productID, warehouseID uint64, batch string) float64 {
|
||
var s models.ErpStock
|
||
if err := models.Orm.QueryTable(new(models.ErpStock)).
|
||
Filter("tenant_id", tid).Filter("product_id", productID).
|
||
Filter("warehouse_id", warehouseID).Filter("batch_no", batch).
|
||
One(&s, "cost_price"); err == nil {
|
||
return s.CostPrice
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// erpReturnedQty 某原单行已退数量(仅统计已生效退货单,排除 excludeDocID)
|
||
func erpReturnedQty(tid string, refItemID, excludeDocID uint64) float64 {
|
||
qs := models.Orm.QueryTable(new(models.ErpDocumentItem)).
|
||
Filter("tenant_id", tid).Filter("ref_item_id", refItemID)
|
||
if excludeDocID > 0 {
|
||
qs = qs.Exclude("doc_id", excludeDocID)
|
||
}
|
||
var sibs []models.ErpDocumentItem
|
||
_, _ = qs.All(&sibs, "doc_id", "quantity")
|
||
if len(sibs) == 0 {
|
||
return 0
|
||
}
|
||
ids := make([]uint64, 0, len(sibs))
|
||
for _, s := range sibs {
|
||
ids = append(ids, s.DocID)
|
||
}
|
||
var docs []models.ErpDocument
|
||
_, _ = models.Orm.QueryTable(new(models.ErpDocument)).
|
||
Filter("id__in", ids).Filter("tenant_id", tid).
|
||
Filter("status", models.ErpDocStatusApproved).
|
||
Filter("delete_time__isnull", true).All(&docs, "id")
|
||
alive := map[uint64]bool{}
|
||
for _, d := range docs {
|
||
alive[d.ID] = true
|
||
}
|
||
var sum float64
|
||
for _, s := range sibs {
|
||
if alive[s.DocID] {
|
||
sum += s.Quantity
|
||
}
|
||
}
|
||
return sum
|
||
}
|
||
|
||
// erpPickFlowID 单据类型可用审批流:先精确匹配 erp_xxx,再回落到模块级 erp。
|
||
// 部门适用范围、启用状态等匹配规则统一交给引擎的 ListAvailableFlows,避免两处口径不一致。
|
||
func erpPickFlowID(tid, orgID uint64, bizType string) uint64 {
|
||
for _, bt := range []string{bizType, "erp"} {
|
||
flows, err := approvalEngine.ListAvailableFlows(models.ApprovalScopeTenant, tid, bt, orgID)
|
||
if err == nil && len(flows) > 0 {
|
||
return flows[0].FlowID
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func docTypeLabelOf(t string) string { return models.ErpDocTypeLabel[t] }
|
||
|
||
func round4f(v float64) float64 {
|
||
return math.Round(v*10000) / 10000
|
||
}
|