Files
platforms/backend/api/internal/logic/delivery/order.go
2026-08-31 22:04:22 +08:00

631 lines
22 KiB
Go
Raw 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.
// 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。
// 版本v1.5.0。
package delivery
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
platformgasorder "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/gasorder"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var errContractUserOutsidePoint = errors.New("签约用户已不属于当前配送点,只允许查看或终止合同")
func rewriteJSON(ctx *gin.Context, mutate func(map[string]any) bool) bool {
body, err := io.ReadAll(ctx.Request.Body)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return false
}
var values map[string]any
if json.Unmarshal(body, &values) != nil || !mutate(values) {
if !ctx.IsAborted() {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return false
}
body, _ = json.Marshal(values)
ctx.Request.Body = io.NopCloser(bytes.NewReader(body))
return true
}
func scopedContract(ctx *gin.Context, identity string, pointID uint64) (models.GasorderContract, bool) {
var contract models.GasorderContract
if err := common.ActiveRecords(db()).Where("identity = ? AND delivery_basic_id = ?", identity, pointID).First(&contract).Error; err != nil {
common.RespondRecordError(ctx, err)
return contract, false
}
return contract, true
}
// contractUserActiveAtPoint 校验合同用户仍属于合同气站及当前配送点。
func contractUserActiveAtPoint(databaseService *gorm.DB, contract models.GasorderContract, pointID uint64) bool {
var count int64
err := databaseService.Model(&models.UserServiceRelation{}).
Where("user_account_id = ? AND gas_basic_id = ? AND delivery_basic_id = ? AND status <> ?",
contract.UserAccountID, contract.GasBasicID, pointID, common.StatusArchived).
Count(&count).Error
return err == nil && count > 0
}
// deliveryContractListQuery 构造当前配送点合同列表;订单候选模式额外限定可履约状态。
func deliveryContractListQuery(database *gorm.DB, gasID, pointID uint64, candidate string, now time.Time) *gorm.DB {
query := platformgasorder.ContractPartyDisplayQuery(database).
Where("gasorder_contract.gas_basic_id = ? AND gasorder_contract.delivery_basic_id = ?", gasID, pointID)
if candidate == "order" {
return platformgasorder.FilterGasorderContractCandidates(query, "order", now)
}
return query
}
func ListContract(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
page, size := common.PageSize(ctx)
query := deliveryContractListQuery(
db(), point.GasBasicID, point.ID, strings.TrimSpace(ctx.Query("candidate")), time.Now(),
)
query = common.ApplyKeywordFilter(ctx, query, &models.GasorderContract{})
var total int64
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
// 空结果也初始化为空切片,确保公开响应保持 JSON 数组而不是 null。
list := make([]platformgasorder.ContractPartyDisplay, 0)
if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Scan(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
response, err := common.PublicResourceResponse(list)
if err != nil {
infra.Response.Error(ctx, err)
return
}
contracts := make([]models.GasorderContract, 0, len(list))
for _, item := range list {
contracts = append(contracts, item.GasorderContract)
}
protected, err := protectDeliveryContractListResponse(ctx, response, contracts)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": protected})
}
// protectDeliveryContractListResponse 删除内部附件路径,仅补充无敏感信息的存在性标识。
func protectDeliveryContractListResponse(ctx *gin.Context, response any, list []models.GasorderContract) ([]any, error) {
protected := common.ProtectPreciseLocation(ctx, &models.GasorderContract{}, response)
items, valid := protected.([]any)
if !valid || len(items) != len(list) {
return nil, errors.New("配送合同列表响应结构无效")
}
for index, item := range items {
row, rowValid := item.(map[string]any)
if !rowValid {
return nil, errors.New("配送合同列表记录结构无效")
}
// 只返回是否存在附件,内部存储 URI 已由统一保护层删除。
row["has_attachment"] = strings.TrimSpace(list[index].FileURI) != ""
}
return items, nil
}
func GetContract(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if ok {
if _, valid := scopedContract(ctx, ctx.Param("identity"), point.ID); valid {
platformgasorder.GetGasorderContract(ctx)
}
}
}
// UploadContractAttachment 在当前配送点登录范围内上传受控 PDF 临时附件。
func UploadContractAttachment(ctx *gin.Context) {
if _, _, ok := currentScope(ctx); !ok {
return
}
platformgasorder.UploadGasorderContractAttachment(ctx)
}
// CleanupContractAttachment 清理当前配送点操作人尚未绑定的临时附件。
func CleanupContractAttachment(ctx *gin.Context) {
if _, _, ok := currentScope(ctx); !ok {
return
}
platformgasorder.CleanupGasorderContractAttachment(ctx)
}
// ServeContractAttachment 仅预览当前配送点拥有的正式合同附件。
func ServeContractAttachment(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
if _, valid := scopedContract(ctx, ctx.Param("identity"), point.ID); valid {
platformgasorder.ServeGasorderContractAttachment(ctx)
}
}
func CreateContract(ctx *gin.Context) {
point, station, ok := currentScope(ctx)
if !ok {
return
}
if !rewriteJSON(ctx, func(values map[string]any) bool {
userIdentity, _ := values["user_account_identity"].(string)
if _, _, valid := scopedUser(ctx, userIdentity, point.ID); !valid {
return false
}
values["gas_basic_identity"] = station.Identity
values["delivery_basic_identity"] = point.Identity
return true
}) {
return
}
platformgasorder.CreateGasorderContract(ctx)
}
func withContract(ctx *gin.Context, handler gin.HandlerFunc) {
point, _, ok := currentScope(ctx)
if ok {
if _, valid := scopedContract(ctx, ctx.Param("identity"), point.ID); valid {
handler(ctx)
}
}
}
func UpdateContract(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
if _, valid := scopedContract(ctx, ctx.Param("identity"), point.ID); !valid {
return
}
if !rewriteJSON(ctx, func(values map[string]any) bool {
values["delivery_basic_identity"] = point.Identity
return true
}) {
return
}
platformgasorder.UpdateGasorderContract(ctx)
}
func ActivateContract(ctx *gin.Context) { withContract(ctx, platformgasorder.ActivateGasorderContract) }
func RenewContract(ctx *gin.Context) { withContract(ctx, platformgasorder.RenewGasorderContract) }
func TerminateContract(ctx *gin.Context) {
withContract(ctx, platformgasorder.TerminateGasorderContract)
}
// deliveryContractProductQuery 构造当前配送点合同气瓶查询;指定合同时仅保留未解绑候选。
func deliveryContractProductQuery(database *gorm.DB, pointID, contractID uint64, keyword string) *gorm.DB {
query := common.ActiveRecords(database.Model(&models.GasorderContractProduct{})).
Select("gasorder_contract_product.*, COALESCE(display_product.name, '') AS product_name").
Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id").
Joins("LEFT JOIN product_info AS display_product ON display_product.id = gasorder_contract_product.product_info_id AND display_product.deleted_at IS NULL AND display_product.status <> ?", common.StatusArchived).
Where("gasorder_contract.delivery_basic_id = ?", pointID)
if contractID > 0 {
query = query.Where("gasorder_contract_product.gasorder_contract_id = ? AND gasorder_contract_product.unbound_at IS NULL", contractID)
}
keyword = strings.ToLower(strings.TrimSpace(keyword))
if keyword == "" {
return query
}
pattern := "%" + keyword + "%"
return query.Where("(LOWER(COALESCE(display_product.name, '')) LIKE ? OR LOWER(gasorder_contract_product.product_code) LIKE ? OR LOWER(gasorder_contract_product.product_type_name) LIKE ?)", pattern, pattern, pattern)
}
func ListContractProduct(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
contractIdentity := strings.TrimSpace(ctx.Query("contract_identity"))
var contractID uint64
if contractIdentity != "" {
contract, valid := scopedContract(ctx, contractIdentity, point.ID)
if !valid {
return
}
contractID = contract.ID
}
query := deliveryContractProductQuery(db(), point.ID, contractID, ctx.Query("keyword"))
page, size := common.PageSize(ctx)
var total int64
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
type contractProductDisplay struct {
models.GasorderContractProduct
ProductName string `gorm:"column:product_name" json:"product_name"`
}
var list []contractProductDisplay
if err := query.Order("gasorder_contract_product.created_at desc").Offset((page - 1) * size).Limit(size).Scan(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
response, err := common.PublicResourceResponse(list)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{
"total": total,
"list": common.ProtectPreciseLocation(ctx, &models.GasorderContractProduct{}, response),
})
}
func GetContractProduct(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
var item models.GasorderContractProduct
query := common.ActiveRecords(db().Model(&models.GasorderContractProduct{})).
Select("gasorder_contract_product.*").
Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id").
Where("gasorder_contract_product.identity = ? AND gasorder_contract.delivery_basic_id = ?", ctx.Param("identity"), point.ID)
respondRecord(ctx, query, &item)
}
func BindContractProduct(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
if !rewriteJSON(ctx, func(values map[string]any) bool {
identity, _ := values["gasorder_contract_identity"].(string)
contract, valid := scopedContract(ctx, identity, point.ID)
if !valid {
return false
}
if !contractUserActiveAtPoint(db(), contract, point.ID) {
infra.Response.Error(ctx, errContractUserOutsidePoint)
return false
}
return true
}) {
return
}
platformgasorder.BindGasorderContractProduct(ctx)
}
func UnbindContractProduct(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
var count int64
err := db().Model(&models.GasorderContractProduct{}).
Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id").
Where("gasorder_contract_product.identity = ? AND gasorder_contract.delivery_basic_id = ?", ctx.Param("identity"), point.ID).
Count(&count).Error
if err != nil || count != 1 {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
platformgasorder.UnbindGasorderContractProduct(ctx)
}
func ListContractRevision(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
query := common.ActiveRecords(db().Model(&models.GasorderContractRevision{})).
Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_revision.gasorder_contract_id").
Where("gasorder_contract.delivery_basic_id = ?", point.ID)
listScoped(ctx, &models.GasorderContractRevision{}, query, "gasorder_contract_revision.created_at desc")
}
func GetContractRevision(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
var item models.GasorderContractRevision
query := common.ActiveRecords(db().Model(&models.GasorderContractRevision{})).
Select("gasorder_contract_revision.*").
Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_revision.gasorder_contract_id").
Where("gasorder_contract_revision.identity = ? AND gasorder_contract.delivery_basic_id = ?", ctx.Param("identity"), point.ID)
respondRecord(ctx, query, &item)
}
// deliveryContractProductCandidateQuery 按合同用户及当前配送点服务关系限定候选气阀。
func deliveryContractProductCandidateQuery(databaseService *gorm.DB, contract models.GasorderContract, pointID uint64) *gorm.DB {
query := common.ActiveRecords(databaseService.Model(&models.ProductInfo{}))
return platformgasorder.ContractProductCandidateQuery(query, contract).
Where(`EXISTS (
SELECT 1 FROM user_service_relation AS candidate_relation
WHERE candidate_relation.user_account_id = product_info.user_account_id
AND candidate_relation.gas_basic_id = ?
AND candidate_relation.delivery_basic_id = ?
AND candidate_relation.status <> ?
AND candidate_relation.deleted_at IS NULL
)`, contract.GasBasicID, pointID, common.StatusArchived)
}
// ListProductCandidate 仅返回属于指定合同用户且仍在当前配送点服务范围内的候选气阀。
func ListProductCandidate(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
query := common.ActiveRecords(db().Model(&models.ProductInfo{})).Where("1 = 0")
if contractIdentity := strings.TrimSpace(ctx.Query("contract_identity")); contractIdentity != "" {
contract, valid := scopedContract(ctx, contractIdentity, point.ID)
if !valid {
return
}
query = deliveryContractProductCandidateQuery(db(), contract, point.ID)
}
listScoped(ctx, &models.ProductInfo{}, query, "product_info.created_at desc")
}
// GetProductCandidate 返回指定合同用户范围内的单条候选气阀。
func GetProductCandidate(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
var item models.ProductInfo
contractIdentity := strings.TrimSpace(ctx.Query("contract_identity"))
query := common.ActiveRecords(db().Model(&models.ProductInfo{})).Where("1 = 0")
if contractIdentity != "" {
contract, valid := scopedContract(ctx, contractIdentity, point.ID)
if !valid {
return
}
query = deliveryContractProductCandidateQuery(db(), contract, point.ID)
}
query = query.Select("product_info.*").Where("product_info.identity = ?", ctx.Param("identity"))
respondRecord(ctx, query, &item)
}
func scopedOrder(ctx *gin.Context, identity string, pointID uint64) (models.GasorderBasic, bool) {
var order models.GasorderBasic
if err := common.ActiveRecords(db()).Where("identity = ? AND delivery_basic_id = ?", identity, pointID).First(&order).Error; err != nil {
common.RespondRecordError(ctx, err)
return order, false
}
return order, true
}
func ListOrder(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
page, size := common.PageSize(ctx)
query := common.ApplyKeywordFilter(ctx,
common.ActiveRecords(db().Model(&models.GasorderBasic{})).Where("delivery_basic_id = ?", point.ID),
&models.GasorderBasic{},
)
var total int64
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
var orders []models.GasorderBasic
if err := query.Order("gasorder_basic.created_at desc").Offset((page - 1) * size).Limit(size).Find(&orders).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
response, err := platformgasorder.BuildGasorderListResponse(ctx, orders)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": restoreDeliveryOrderListContacts(response, orders)})
}
// restoreDeliveryOrderListContacts 为当前配送点管理员恢复本点订单联系人快照明文。
// 调用前订单必须已经按当前配送点完成范围过滤,避免跨配送点暴露联系人信息。
func restoreDeliveryOrderListContacts(response any, orders []models.GasorderBasic) any {
list, ok := response.([]any)
if !ok {
return response
}
for index, item := range list {
if index >= len(orders) {
break
}
record, ok := item.(map[string]any)
if !ok {
continue
}
record["contact_name"] = orders[index].ContactName
record["contact_phone"] = orders[index].ContactPhone
delete(record, "contact_name_masked")
delete(record, "contact_phone_masked")
}
return response
}
func GetOrder(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if ok {
if _, valid := scopedOrder(ctx, ctx.Param("identity"), point.ID); valid {
platformgasorder.GetGasorderBasic(ctx)
}
}
}
func CreateOrder(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
if !rewriteJSON(ctx, func(values map[string]any) bool {
identity, _ := values["gasorder_contract_identity"].(string)
if _, valid := scopedContract(ctx, identity, point.ID); !valid {
return false
}
values["creator_type"] = "delivery"
values["creator_identity"] = point.Identity
return true
}) {
return
}
platformgasorder.CreateGasorderBasic(ctx)
}
func AssignOrder(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
if _, valid := scopedOrder(ctx, ctx.Param("identity"), point.ID); !valid {
return
}
if !rewriteJSON(ctx, func(values map[string]any) bool {
staffIdentity, _ := values["staff_account_identity"].(string)
staff, valid := scopedStaff(ctx, staffIdentity, point)
if !valid || staff.WorkStatus != "on_duty" || staff.Status != common.StatusEnable {
return false
}
var credentialCount int64
err := common.ActiveRecords(db().Model(&models.StaffCredential{})).
Where("staff_account_id = ? AND (expired_at IS NULL OR expired_at > ?)", staff.ID, time.Now()).
Count(&credentialCount).Error
if err != nil || credentialCount == 0 {
return false
}
values["delivery_basic_identity"] = point.Identity
return true
}) {
return
}
platformgasorder.AssignGasorderBasic(ctx)
}
func withOrder(ctx *gin.Context, handler gin.HandlerFunc) {
point, _, ok := currentScope(ctx)
if ok {
if _, valid := scopedOrder(ctx, ctx.Param("identity"), point.ID); valid {
handler(ctx)
}
}
}
func OrderException(ctx *gin.Context) { withOrder(ctx, platformgasorder.GasorderException) }
func OrderRecover(ctx *gin.Context) { withOrder(ctx, platformgasorder.GasorderRecover) }
func OrderCancel(ctx *gin.Context) { withOrder(ctx, platformgasorder.GasorderCancel) }
func ReclaimOrder(ctx *gin.Context) {
account, point, _, ok := CurrentDeliveryAccount(ctx)
if !ok {
return
}
var request struct {
Reason string `json:"reason" binding:"required,max=1000"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
err := db().Transaction(func(tx *gorm.DB) error {
var order models.GasorderBasic
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("identity = ? AND delivery_basic_id = ?", ctx.Param("identity"), point.ID).First(&order).Error; err != nil {
return err
}
if order.OrderStatus != common.StatusAssigned {
return errors.New("only assigned order can be reclaimed")
}
if err := tx.Model(&order).Updates(map[string]any{
"staff_account_id": 0, "order_status": common.StatusCreated,
}).Error; err != nil {
return err
}
return tx.Create(&models.GasorderStatus{
Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID,
FromStatus: common.StatusAssigned, ToStatus: common.StatusCreated,
OperatorIdentity: account.Identity, OperatorName: account.DisplayName,
OccurredAt: time.Now(), Reason: request.Reason,
}).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, gin.H{"updated": true, "order_status": common.StatusCreated})
}
func AdjustOrderAmount(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
var request struct {
DeliveryFee int64 `json:"delivery_fee"`
DiscountAmount int64 `json:"discount_amount"`
Reason string `json:"reason" binding:"required,max=1000"`
}
if err := ctx.ShouldBindJSON(&request); err != nil || request.DeliveryFee < 0 || request.DiscountAmount < 0 {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
account, _, _, _ := CurrentDeliveryAccount(ctx)
err := db().Transaction(func(tx *gorm.DB) error {
var order models.GasorderBasic
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("identity = ? AND delivery_basic_id = ?", ctx.Param("identity"), point.ID).First(&order).Error; err != nil {
return err
}
if order.OrderStatus != common.StatusCreated && order.OrderStatus != common.StatusAssigned {
return errors.New("order amount cannot be adjusted")
}
var paymentCount int64
if err := tx.Model(&models.GasorderPayment{}).
Joins("JOIN payment_order ON payment_order.id = gasorder_payment.payment_order_id").
Where("gasorder_payment.gasorder_basic_id = ? AND payment_order.payment_status = ?", order.ID, 23).
Count(&paymentCount).Error; err != nil || paymentCount > 0 {
return errors.New("paid order cannot be adjusted")
}
if order.ProductAmount > math.MaxInt64-request.DeliveryFee {
return errors.New("payable amount overflow")
}
payable := order.ProductAmount + request.DeliveryFee - request.DiscountAmount
if payable <= 0 {
return errors.New("payable amount must be positive")
}
if err := tx.Model(&order).Updates(map[string]any{
"delivery_fee": request.DeliveryFee, "discount_amount": request.DiscountAmount, "payable_amount": payable,
}).Error; err != nil {
return err
}
record := models.GasorderStatus{
Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID,
FromStatus: order.OrderStatus, ToStatus: order.OrderStatus,
OperatorIdentity: account.Identity, OperatorName: account.DisplayName, OccurredAt: time.Now(),
Reason: fmt.Sprintf("%s配送费 %d→%d优惠金额 %d→%d应付金额 %d→%d",
request.Reason, order.DeliveryFee, request.DeliveryFee, order.DiscountAmount, request.DiscountAmount,
order.PayableAmount, payable),
}
return tx.Create(&record).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, gin.H{"updated": true})
}