This commit is contained in:
hero920103 committed 2026-10-19 01:13:17 +08:00
1 parent 5718a00e31
commit 1850ac640e
226 files changed
+37772 -12071

No files matched your search

+468
View File
@@ -0,0 +1,468 @@
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
}