Files
2026-10-19 01:13:17 +08:00

469 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}