GBM预约书appointment_book详细解析

appointment_book POST 预约书流程详细解析

本文只分析 appointment/view.py 中这个路由的 POST 分支:

@appointment_api.route("/appointment_book", methods=['GET', 'POST'])

对应入口函数是 get_appointment_book()。虽然函数名叫 get_appointment_book,但 POST 分支在购买流程里承担的是“进入购买流程前,准备一份可用预约书”的职责。

1. 结论先行

POST /appointment_book 的核心作用是:

  1. 根据手机号、产品、主体,找到当前客户这次购买流程应该使用的 AppointmentBook;
  2. 如果已有一份“未结束”的预约书,就复用原记录;
  3. 如果最近一份预约书已经结束,或者没有可用预约书,就新建一份 AppointmentBook,同时新建一份对应的 PreAppointmentContract;
  4. 如果请求体里带了 order_id,会把该订单里的金额、预计打款日、申购日等基础信息同步到预约书上,用于购买流程页面预填;
  5. 返回预约书 id、预约合同 id、金额/日期等基础字段,以及额度相关字段。

最关键的结束标志不是存在 AppointmentBook 上,而是存在它关联的 PreAppointmentContract 上:

PreAppointmentContract.custom_sign_apply_time

这个字段有值,表示这份预约书相关流程已经被使用并结束;没有值,表示这份预约书还没结束,可以继续复用。

需要特别注意:POST /appointment_book 本身不会把预约书置为结束。真正设置 custom_sign_apply_time 的地方在后续下单/接力完成流程里,主要是 api/ops.py 的:

  • appointment_order(...)
  • finish_gbm_appointment(...)

通常购买流程后续会调用 POST /set_order_base_info,它会进一步创建订单或接力顾问侧订单;当订单创建成功后,才会把 custom_sign_apply_time 写上当前时间。

2. 涉及的数据模型

2.1 AppointmentBook

位置:appointment/models.py

class AppointmentBook(document.DynamicDocument):
    subject = fields.StringField()                    # 公司主体
    product_code = fields.StringField(required=True)  # 产品code
    custom_id = fields.ObjectIdField(required=True)   # 客户id

    apply_amount = fields.DecimalField()              # 金额
    apply_unit = fields.StringField()                 # 单位
    apply_currency = fields.StringField()             # 币种
    custom_estimate_wire_date = fields.DateTimeField()# 客户预计打款时间
    apply_date = fields.DateTimeField()               # 申购时间

    pre_appointment_contract_id = fields.ObjectIdField(required=True)
    created = fields.DateTimeField(default=datetime.datetime.now)

AppointmentBook 可以理解为购买流程里的“预约书主记录”。它保存这次预约的主体、产品、客户、金额、预计打款日期、申购日期,并通过 pre_appointment_contract_id 关联一份 PreAppointmentContract。

索引是:

("subject", "product_code", "custom_id")

所以 POST 复用时也是按这三个维度找当前客户、当前产品、当前主体下最近的一份预约书。

2.2 PreAppointmentContract

位置:appointment/models.py

class PreAppointmentContract(document.DynamicDocument):
    product_code = fields.StringField(required=True)
    custom_id = fields.ObjectIdField(required=True)

    custom_sign_apply_time = fields.DateTimeField()   # 客户签署时间

    goal_confirm_id = fields.ObjectIdField()
    relation_record_id = fields.ObjectIdField()
    risk_notification_id = fields.ObjectIdField()
    risk_disclosure_id = fields.ObjectIdField()
    money_confirm_id = fields.ObjectIdField()

    custom_risk_level = fields.StringField()
    product_info = fields.DictField()

    R5_notify_read = fields.BooleanField()
    read_notify = fields.BooleanField()
    contract_main_terms_notify = fields.BooleanField()
    carry_tips = fields.BooleanField()

PreAppointmentContract 可以理解为“预约书相关合同/适当性文件集合记录”。它既保存风险告知书、风险揭示书、投资目的确认书、自有资金确认书等文件 id,也保存阅读状态。

本流程最重要的是:

custom_sign_apply_time

虽然注释写的是“客户签署时间”,但从当前代码使用方式看,它承担的是“这份预约书是否已经完成/结束”的业务标志。

2.3 关系图

flowchart LR A["AppointmentBook<br/>预约书主记录"] -->|pre_appointment_contract_id| B["PreAppointmentContract<br/>预约相关合同/文件记录"] C["Order<br/>订单"] -->|appointment_book_id| A

一份订单会通过 Order.appointment_book_id 反向挂到预约书上。订单里也能通过预约书找到 PreAppointmentContract,进而找到风险告知书、风险揭示书等文件。

3. POST 入口参数

POST 分支虽然用的是 POST,但一部分参数来自 query string,一部分来自 JSON body。

3.1 query string 参数

入口最开始读取:

phone = flask.request.args['phone']
product_code = flask.request.args.get('product_code')

所以 phone 是必填 query 参数。

如果 query 里没有 product_code,代码会继续从 query 里的 order_id 推导产品:

if not product_code:
    order_id = flask.request.args.get('order_id')
    product_code = order_method.get_product_code(
        order_method.get_order_by_id(order_id))

这里有一个容易踩的点:这个兜底取的是 request.args.get('order_id'),不是 request.json.get('order_id')。也就是说,如果 POST 请求只在 JSON body 里传了 order_id,但 query 里没有 product_code,这里拿不到产品 code。

3.2 JSON body 参数

POST 分支会读取:

subject = flask.request.json.get("subject")
order_id = flask.request.json.get('order_id')

subject 不传时默认:

subject = "FUXIN"

之后会统一转大写:

subject.upper()

order_id 是可选的。它决定 POST 返回前是否把已有订单的金额/日期信息同步到预约书上。

4. POST 分支整体流程

POST 主体代码如下:

subject = flask.request.json.get("subject")
if not subject:
    subject = "FUXIN"

context = ops.get_appointment_context(custom, product_obj, states, subject.upper())

appointment_book = ops.create_appointment_book(
    subject.upper(), custom, product_obj, **context
)

order_id = flask.request.json.get('order_id')
if order_id:
    ...
    ops.set_appointment_base_info(appointment_book, amount, wire_date, apply_date)
    ...
    return api.APIResult(sc.E_SUCC, ret)

res = ops.appointment_book2dict(appointment_book)
...
return api.APIResult(sc.E_SUCC, res)

可以拆成四步:

  1. 准备客户、产品、状态、额度信息;
  2. 生成购买流程上下文 context;
  3. 调用 ops.create_appointment_book(...) 复用或新建预约书;
  4. 根据是否带 order_id 返回不同的预填结果。

5. 进入 POST 前的公共准备逻辑

GET 和 POST 共用函数前半段,所以 POST 也会先走这些逻辑。

5.1 找客户

phone = flask.request.args['phone']
custom = custom_ops.get_custom_by_phone(phone)

这里没有显式判空。如果手机号找不到客户,后面访问 custom.id 时会出异常。

5.2 找产品

product_code = flask.request.args.get('product_code')
if not product_code:
    order_id = flask.request.args.get('order_id')
    product_code = order_method.get_product_code(order_method.get_order_by_id(order_id))
product_obj = product_util.get_product_objs([product_code])[product_code]

优先用 query 里的 product_code。没有时,才用 query 里的 order_id 找订单,再从订单里取产品 code。

5.3 找客户当前状态

states = custom_states.get_custom_current_states(custom)

后续 get_appointment_context 会从 states 中取客户身份信息、风险测评等级、证件上下文等。

5.4 查额度并过滤

quota_item = os_util.get_latest_approved_quota(str(custom.id), product_code)
associated_order_id = quota_item.get('associated_order_id')

如果额度绑定了订单,会判断这个订单是否已经关闭:

if associated_order_id:
    order = order_method.get_order_by_id(associated_order_id)
    if not order:
        quota_item = {}
    else:
        if order.state != '关闭':
            quota_item = {}

含义是:

  • 额度没有绑定订单:可以返回;
  • 额度绑定了订单,而且订单状态是 关闭:可以返回;
  • 额度绑定了订单,但订单不是 关闭:过滤掉,不返回额度数据;
  • 额度绑定的订单查不到:过滤掉。

最终 POST 会把这些字段返回给前端:

quota_apply_amount
quota_custom_estimate_apply_date
quota_latest_updated_time

额度逻辑不参与预约书是否复用,只影响购买页展示/预填。

6. context 是什么

POST 会先调用:

context = ops.get_appointment_context(custom, product_obj, states, subject.upper())

这个函数在 appointment/ops.py 中,核心内容是:

is_append = order_base_ops.is_addition_order(custom.id, product_obj.get('product_code'))
n, t, c = custom_states.get_custom_identity_info(state)
cert_context = custom_states.get_cert_context(state)
quiz_level = cert_context['quiz_level']

context = {
    "notmatched": not product_util.is_risk_level_matched(product_obj, quiz_level),
    "name": n,
    "identity_type": t,
    "identity_code": c
}
context.update(**cert_context)
context.update(**contract_ops.get_proof_context(custom, t, c))
context.update(**product_util.get_product_context(product_obj, subject))
context.update({'is_append': is_append})

它主要组装这些信息:

  • 客户姓名、证件类型、证件号码;
  • 客户风险等级 quiz_level;
  • 产品风险等级、产品名称、主体、模板相关上下文;
  • 风险是否匹配 notmatched;
  • 是否追加 is_append;
  • 生成合同/证明文件时需要的 proof 上下文。

create_appointment_book 新建 PreAppointmentContract 时会用到其中的:

context.get("quiz_level")

用来记录当时客户的风险等级。

7. 核心:如何复用或新建预约书

POST 的核心调用是:

appointment_book = ops.create_appointment_book(
    subject.upper(), custom, product_obj, **context
)

注意:POST 没有传 appointment_book_id。这对复用逻辑很关键。

7.1 create_appointment_book 的主流程

位置:appointment/ops.py

def create_appointment_book(subject, custom, product_obj, appointment_book_id=None, **context):
    product_code = product_util.get_product_code(product_obj)
    flag, appointment_book = check_appointment_book_exist_valid(
        subject, custom, product_obj, appointment_book_id, **context
    )
    if flag:
        return appointment_book

    product_info = dict(
        risk_level=product_obj.get("risk_level"),
        subject=subject
    )

    pre_appointment_contract = meta.create_pre_appointment_contract(custom.id, product_code)
    pre_appointment_contract.custom_risk_level = context.get("quiz_level")
    pre_appointment_contract.product_info = product_info
    meta.commit_pre_appointment_contract(pre_appointment_contract)

    appointment_book = meta.get_appointment_book_by_id(appointment_book_id)
    if appointment_book:
        appointment_book.pre_appointment_contract_id = pre_appointment_contract.id
    else:
        appointment_book = meta.create_appointment_book(
            subject, custom.id, product_code, pre_appointment_contract.id
        )
    meta.commit_appointment_book(appointment_book)
    return appointment_book

它先调用 check_appointment_book_exist_valid(...)。如果返回 flag=True,说明已有可用预约书,直接复用。

如果 flag=False,就新建一份 PreAppointmentContract。然后:

  • 如果传了 appointment_book_id 且能找到旧预约书,就只替换旧预约书的 pre_appointment_contract_id;
  • 如果没有传 appointment_book_id,就新建一份 AppointmentBook。

POST 不传 appointment_book_id,所以 POST 在需要新建时,会新建一份新的 AppointmentBook。

7.2 check_appointment_book_exist_valid 的判断逻辑

位置:appointment/ops.py

def check_appointment_book_exist_valid(subject, custom, product_obj, appointment_book_id=None, **context):
    product_code = product_util.get_product_code(product_obj)
    if not appointment_book_id:
        params = dict(
            subject=subject,
            product_code=product_code,
            custom_id=custom.id
        )
    else:
        params = dict(id=appointment_book_id)

    appointment_book = meta.get_appointment_book(**params)
    pre_appointment_contract_id = appointment_book and appointment_book.pre_appointment_contract_id

    if pre_appointment_contract_id:
        pre_appointment_contract = meta.get_pre_appointment_contract_by_id(pre_appointment_contract_id)
        if pre_appointment_contract and pre_appointment_contract.custom_sign_apply_time and appointment_book_id:
            return True, appointment_book
        if pre_appointment_contract and not pre_appointment_contract.custom_sign_apply_time:
            if subject != appointment_book.subject:
                return False, None
            return True, appointment_book

    return False, appointment_book

这个函数的关键点:

  1. POST 没传 appointment_book_id,所以会按 subject + product_code + custom_id 找最近一份预约书;

  2. meta.get_appointment_book(**params) 内部是:

    models.AppointmentBook.objects(**params).order_by("-created").first()
    

    也就是只看最新的一份;

  3. 找到预约书后,会继续找它关联的 PreAppointmentContract;

  4. 如果 custom_sign_apply_time 没有值,说明未结束,返回 True,复用;

  5. 如果 custom_sign_apply_time 有值,POST 场景下不会复用,因为 POST 没传 appointment_book_id;

  6. 如果没有找到预约书、没有关联合同、或者关联合同不存在,都会返回 False,进入新建流程。

7.3 POST 场景下的复用规则

因为 POST 不传 appointment_book_id,所以可以把规则简化成:

按 subject + product_code + custom_id 找最新 AppointmentBook
    找不到 => 新建
    找到了,但没有有效 PreAppointmentContract => 新建
    找到了,PreAppointmentContract.custom_sign_apply_time 为空 => 复用
    找到了,PreAppointmentContract.custom_sign_apply_time 有值 => 新建

7.4 场景表

场景 POST 处理结果 原因
客户第一次进入该产品购买流程 新建 PreAppointmentContract + 新建 AppointmentBook 找不到同主体、同产品、同客户的预约书
客户进入后退出,未提交订单,再次进入 复用原 AppointmentBook 原预约书关联的 custom_sign_apply_time 为空
客户已经通过后续流程成功创建订单,再次进入 新建一份预约书 上一份预约书关联的 custom_sign_apply_time 已有值
同客户同产品但换了主体 新建 查询条件包含 subject,不同主体查不到原预约书
最新预约书的 pre_appointment_contract_id 指向不存在的记录 新建 无法确认它是未结束的有效预约书
存在多份历史预约书 只看最新一份 查询使用 order_by("-created").first()

8. “预约书未结束用原来的记录”是怎么做到的

关键代码在这里:

if pre_appointment_contract and not pre_appointment_contract.custom_sign_apply_time:
    if subject != appointment_book.subject:
        return False, None
    return True, appointment_book

也就是说,只要最新预约书关联的 PreAppointmentContract.custom_sign_apply_time 为空,就认为这份预约书还没有完成购买流程,可以继续使用。

POST 重新进入时,接口会返回同一个 AppointmentBook.id 和同一个 pre_appointment_contract_id,前端后续继续使用这组 id 往下走。

这个设计的效果是:

  1. 用户重复进入购买流程,不会不断生成新的预约书;
  2. 用户中途退出再回来,可以沿用之前那份预约书;
  3. 用户只有在上一次流程真正生成订单后,才会进入下一轮预约书。

9. “预约书结束”的标志是什么

结束标志是:

PreAppointmentContract.custom_sign_apply_time

字段定义在 appointment/models.py:

custom_sign_apply_time = fields.DateTimeField()  # 客户签署时间

虽然字段名和注释更像“客户签署申请时间”,但在预约书复用逻辑里,它就是判断这份预约书是否结束的标志。

9.1 为什么说它是结束标志

因为 check_appointment_book_exist_valid 用它判断是否可复用:

if pre_appointment_contract and not pre_appointment_contract.custom_sign_apply_time:
    return True, appointment_book

没有值:可以复用。

一旦有值:POST 不会复用,会新建下一份预约书。

9.2 什么时候写入这个字段

POST /appointment_book 不写这个字段。

它是在后续订单创建成功后写入的。

情况 A:没有传订单 id,创建新的预约订单

调用链通常是:

POST /set_order_base_info
  -> appointment.ops.set_order_base_info
  -> appointment.ops.appointment_order
  -> api.ops.appointment_order
  -> order_base.create_order
  -> 写入 pre_app_book.custom_sign_apply_time = now

api.ops.appointment_order 中的关键代码:

code, msg = order_base.create_order(subject, custom.id, product_obj, **data)
if code == sc.E_SUCC:
    appointment_book = meta.get_appointment_book_by_id(appointment_book_id)
    pre_app_book_id = appointment_book and appointment_book.pre_appointment_contract_id
    if pre_app_book_id:
        pre_app_book = meta.get_pre_appointment_contract_by_id(pre_app_book_id)
        if pre_app_book:
            pre_app_book.custom_sign_apply_time = datetime.datetime.now()
            pre_app_book.save()
    order_base.transfer_order_state(msg['order_id'], "审核中")

也就是说,新订单创建成功以后,才会把预约书标记结束。

情况 B:传了订单 id,完成顾问侧已有订单的接力

调用链通常是:

POST /set_order_base_info
  -> appointment.ops.set_order_base_info
  -> appointment.ops.appointment_order
  -> api.ops.finish_gbm_appointment
  -> 关闭原订单
  -> 创建新订单
  -> 写入 pre_app_book.custom_sign_apply_time = now

api.ops.finish_gbm_appointment 中的关键逻辑:

code, msg = order_base.transfer_order_state(
    order.id, '关闭', reason='客户预约系统关闭'
)

data = {
    "order_channel": "simu_app_appointment",
    "relation_order": order.id,
    "appointment_book_id": appointment_book_id,
    ...
}

code, msg = order_base.create_order(subject, order.custom_id, product_obj, **data)
...
appointment_book = meta.get_appointment_book_by_id(appointment_book_id)
pre_app_book_id = appointment_book and appointment_book.pre_appointment_contract_id
if pre_app_book_id:
    pre_app_book = meta.get_pre_appointment_contract_by_id(pre_app_book_id)
    if pre_app_book:
        pre_app_book.custom_sign_apply_time = datetime.datetime.now()
        pre_app_book.save()

这个分支会先把原来的顾问侧订单关闭,关闭原因是:

客户预约系统关闭

然后创建一笔新的 simu_app_appointment 渠道订单,并把新订单挂到当前 appointment_book_id 上。新订单创建成功后,也会把预约书标记结束。

10. POST 带 order_id 时到底做了什么

POST /appointment_book 自己也有一个 order_id 分支:

order_id = flask.request.json.get('order_id')
if order_id:
    ret = {}
    order_base_info = order_method.get_order_appointment_info(order_id)
    amount = order_base_info['apply_amount']
    wire_date = order_base_info['custom_estimate_wire_date']
    apply_date = order_base_info.get('apply_date')
    ops.set_appointment_base_info(appointment_book, amount, wire_date, apply_date)
    ...
    return api.APIResult(sc.E_SUCC, ret)

这里要和后续 /set_order_base_info 区分开。

POST /appointment_book 带 order_id 时,只做三件事:

  1. 读取原订单的预约信息;
  2. 把金额、预计打款日、申购日写入当前预约书;
  3. 返回当前预约书信息和订单的 custom_estimate_apply_date。

它不会:

  • 关闭原订单;
  • 创建新订单;
  • 设置 custom_sign_apply_time;
  • 标记预约书结束。

真正的接力完成是在后续 /set_order_base_info 传 order_id 时发生的。

10.1 原订单信息如何读取

order_method.get_order_appointment_info(order_id) 会返回:

{
    'apply_amount': apply_amount,
    'custom_estimate_wire_date': order.custom_estimate_wire_date,
    'apply_date': order.apply_date,
    'custom_estimate_apply_date': order.custom_estimate_apply_date
}

其中金额会被统一换算为“万元”口径:

if order.apply_unit == '万元':
    apply_amount = order.apply_amount
elif order.apply_unit == '元':
    apply_amount = order.apply_amount / Decimal(10000)
elif order.apply_unit == '亿元':
    apply_amount = order.apply_amount * Decimal(10000)

10.2 写入预约书基础信息

POST 带 order_id 时调用:

ops.set_appointment_base_info(appointment_book, amount, wire_date, apply_date)

实现是:

update_data = {
    "apply_amount": Decimal(apply_amount)
}
if custom_estimate_wire_date:
    update_data.update({'custom_estimate_wire_date': custom_estimate_wire_date})
if apply_date:
    update_data.update({'apply_date': apply_date})
meta.update_appointment_book(appointment_book, **update_data)
meta.commit_appointment_book(appointment_book)

所以这个分支会直接更新 AppointmentBook 的:

  • apply_amount
  • custom_estimate_wire_date
  • apply_date

不会更新 custom_estimate_apply_date 到 AppointmentBook,因为 AppointmentBook 模型本身没有这个字段。它只把 custom_estimate_apply_date 作为返回值给前端。

11. POST 不带 order_id 时做了什么

不带 order_id 时,POST 只返回预约书本身:

res = ops.appointment_book2dict(appointment_book)
quota_apply_amount = os_util.format_amount(quota_apply_amount, res.get('apply_unit'))
res.update(quota_apply_amount=quota_apply_amount)
res.update(quota_custom_estimate_apply_date=quota_custom_estimate_apply_date)
res.update(quota_latest_updated_time=quota_latest_updated_time)
return api.APIResult(sc.E_SUCC, res)

appointment_book2dict 返回:

{
    'id': str(appointment_book.id or ''),
    'product_code': str(appointment_book.product_code),
    'apply_amount': format(appointment_book.apply_amount or 0, '.2f'),
    'apply_currency': appointment_book.apply_currency or '人民币',
    'apply_unit': appointment_book.apply_unit or '万元',
    'wire_date': str(appointment_book.custom_estimate_wire_date or '')[:10],
    "pre_appointment_contract_id": str(appointment_book.pre_appointment_contract_id or ''),
    'apply_date': str(appointment_book.apply_date or '')[:10],
    'payment_time': product_util.get_product_payment_time(product_obj),
    'product_status': product_util.get_product_status(product_obj),
    'estimate_apply_date': ...
}

这个响应主要用于让前端拿到:

  • appointment_book_id,也就是返回里的 id;
  • pre_appointment_contract_id;
  • 已有的金额/日期;
  • 产品开放日、付款时间、产品状态;
  • 可用额度信息。

12. 完整购买流程视角

下面按用户真实购买流程串起来看。

12.1 首次进入购买流程

sequenceDiagram participant FE as 前端购买页 participant AB as POST /appointment_book participant DB as MongoDB FE->>AB: phone + product_code + subject AB->>DB: 按 subject/product/custom 查最新 AppointmentBook DB-->>AB: 没有记录 AB->>DB: 新建 PreAppointmentContract AB->>DB: 新建 AppointmentBook(pre_appointment_contract_id) AB-->>FE: 返回 appointment_book.id 和 pre_appointment_contract_id

结果:

  • 新建 PreAppointmentContract;
  • 新建 AppointmentBook;
  • PreAppointmentContract.custom_sign_apply_time 为空;
  • 这份预约书处于“未结束,可以复用”状态。

12.2 用户中途退出,又重新进入

sequenceDiagram participant FE as 前端购买页 participant AB as POST /appointment_book participant DB as MongoDB FE->>AB: 再次进入购买流程 AB->>DB: 查最新 AppointmentBook DB-->>AB: 找到上一份 AppointmentBook AB->>DB: 查关联 PreAppointmentContract DB-->>AB: custom_sign_apply_time 为空 AB-->>FE: 返回同一份 AppointmentBook

结果:

  • 不新建;
  • 返回同一个 AppointmentBook.id;
  • 返回同一个 pre_appointment_contract_id。

这就是“预约书没结束用原来的记录”的实现。

12.3 用户提交购买信息并成功创建订单

通常后续会调用:

POST /set_order_base_info

这个接口会:

  1. 更新 AppointmentBook.apply_amount/custom_estimate_wire_date/apply_date;

  2. 调用 appointment.ops.appointment_order(...);

  3. 不带 order_id 时创建新的预约订单;

  4. 创建成功后写入:

    pre_app_book.custom_sign_apply_time = datetime.datetime.now()
    

结果:

  • 当前预约书被标记为结束;
  • 订单通过 appointment_book_id 关联这份预约书;
  • 下次用户再进入同产品购买流程时,POST /appointment_book 会新建一份预约书。

12.4 用户从顾问侧已有订单进入购买流程

这个场景 POST /appointment_book 通常会带 order_id。

第一步,进入购买页:

POST /appointment_book

接口会:

  1. 复用或新建一份当前可用预约书;
  2. 从原订单读取金额、预计打款日、申购日;
  3. 写入预约书基础字段;
  4. 返回给前端展示。

此时还没有结束预约书。

第二步,用户继续提交:

POST /set_order_base_info

并带上同一个 order_id。

后续 finish_gbm_appointment 会:

  1. 校验原订单存在、产品可卖、客户问卷完成、专业投资者条件等;
  2. 判断原订单是否可接力;
  3. 将原订单关闭,原因是 客户预约系统关闭;
  4. 创建一笔新的 simu_app_appointment 渠道订单;
  5. 新订单记录 relation_order = 原订单.id;
  6. 新订单记录 appointment_book_id = 当前预约书.id;
  7. 写入 custom_sign_apply_time,预约书结束。

13. 与预约书相关合同/文件的关系

PreAppointmentContract 里有这些字段:

goal_confirm_id
relation_record_id
risk_notification_id
risk_disclosure_id
money_confirm_id

这些是预约流程里可能要签署或确认的文件。

创建 AppointmentBook 时,只会先创建一个空的 PreAppointmentContract,并写入:

custom_risk_level = context.get("quiz_level")
product_info = {
    "risk_level": product_obj.get("risk_level"),
    "subject": subject
}

真正生成风险告知书、风险揭示书、投资目的确认书、自有资金确认书等文件的逻辑在:

appointment.ops.gen_pre_appointment_contract(...)

这个函数会按场景写入:

  • money_confirm_id
  • goal_confirm_id
  • relation_record_id
  • risk_notification_id
  • risk_disclosure_id
  • product_info

但 POST /appointment_book 本身没有直接调用 gen_pre_appointment_contract。

所以可以把职责分开理解:

  • POST /appointment_book:确保“这次购买流程有一份可用预约书主记录和合同集合记录”;
  • gen_pre_appointment_contract:往这份合同集合记录上生成/挂载具体合同文件;
  • set_pre_appointment_verified:设置风险提示、R5、合同主要条款、carry tips 等阅读确认状态;
  • /set_order_base_info 后续下单成功:设置 custom_sign_apply_time,表示这份预约书流程结束。

14. 后续订单如何反向使用预约书文件

Order 模型里有:

appointment_book_id = ObjectIdField()

订单创建时,appointment_book_id 会作为订单字段写入。

后续订单侧获取风险告知书、风险揭示书、投资目的确认书、自有资金确认书时,会先查订单自己的文件;如果没有,会通过 order.appointment_book_id 找预约书,再通过 pre_appointment_contract_id 找预约流程里生成的文件。

例如 order/models.py 里类似逻辑:

if order.appointment_book_id:
    appointment_book = ops.get_appointment_book_by_id(order.appointment_book_id)
    if 'pre_appointment_contract_id' in appointment_book:
        ref = ops.get_pre_appointment_contract(appointment_book['pre_appointment_contract_id'])
        if 'risk_notification_id' in ref and ref['risk_notification_id']:
            rn = FaDaDaNotificationRecord.objects(id=ref['risk_notification_id']).first()

这说明预约书不仅是购买页临时状态,也会成为订单后续查找适当性/签署文件的桥梁。

15. 过期清理逻辑

仓库里还有一个预约书过期清理任务:

appointment/task/expired_custom_pre_appointment.py

核心逻辑:

for p in PreAppointmentContract.objects(
    custom_sign_apply_time__exists=False,
    created__lte=expire_date_end
):
    appointment_book = AppointmentBook.objects(pre_appointment_contract_id=p.id).first()
    if appointment_book:
        order = Order.objects(appointment_book_id=appointment_book.id).first()
        if not order:
            appointment_book.delete()

默认过期天数:

expire_days = 3

含义是:

  • 找出创建时间较早,且 custom_sign_apply_time 不存在的 PreAppointmentContract;
  • 如果它有关联的 AppointmentBook;
  • 并且没有任何订单引用这份 AppointmentBook;
  • 就删除 AppointmentBook。

这个任务只删除 AppointmentBook,没有删除 PreAppointmentContract。所以清理后可能留下孤立的 PreAppointmentContract。

对 POST /appointment_book 的影响是:如果原来的 AppointmentBook 被删了,下一次进入购买流程就查不到它,于是会创建新的预约书。

16. 需要注意的边界点

16.1 POST 没有使用 get_appointment_lock

appointment/ops.py 中定义了:

def get_appointment_lock(custom_id):
    with cache_util.cache_lock('appointment_book_%s' % custom_id):
        with gsdb.switch_rp("PRIMARY"):
            yield

但 create_appointment_book 没有使用这个锁。

如果同一个客户、同一个产品、同一个主体在极短时间内并发请求 POST /appointment_book,理论上可能出现两次都没查到未结束预约书,然后各自创建一份新预约书的情况。

日常前端单次进入一般不会触发,但从代码上看并发幂等不是强保证。

16.2 只看最新一份预约书

复用查询是:

models.AppointmentBook.objects(**params).order_by("-created").first()

如果历史上存在多份预约书,只会判断最新的一份。

因此:

  • 最新一份未结束:复用最新;
  • 最新一份已结束:新建;
  • 即使更老的一份未结束,也不会回退复用更老那份。

16.3 custom_sign_apply_time__exists=False 和字段为空的差异

过期清理任务用的是:

custom_sign_apply_time__exists=False

而复用判断用的是:

not pre_appointment_contract.custom_sign_apply_time

这两个判断略有差异:

  • 复用判断:字段不存在或值为空,都当作未结束;
  • 过期清理:只匹配字段不存在的记录;如果字段存在但值为 None,是否能被匹配取决于 MongoEngine/MongoDB 实际存储形态。

16.4 POST body 的 order_id 不参与产品兜底

如前面所说,公共准备逻辑里如果没有 product_code,只从 query string 取 order_id。

所以这种请求是不稳的:

POST /appointment_book?phone=...
body: {"order_id": "..."}

因为产品 code 兜底拿不到 body 里的 order_id。

更稳的请求方式是:

POST /appointment_book?phone=...&product_code=...
body: {"order_id": "...", "subject": "FUXIN"}

或者:

POST /appointment_book?phone=...&order_id=...
body: {"order_id": "...", "subject": "FUXIN"}

16.5 POST 带 order_id 不代表预约书结束

POST /appointment_book 带 order_id 只是预填预约书信息,不会关闭原订单,也不会创建新订单。

真正结束预约书的是后续订单创建成功后写入:

pre_app_book.custom_sign_apply_time = datetime.datetime.now()

17. 最简伪代码

把 POST /appointment_book 压缩成业务伪代码,大概是:

输入 phone, product_code/order_id, subject

custom = 按 phone 找客户
product = 按 product_code 找产品
states = 当前客户状态
quota = 查询并过滤可用额度
context = 组装客户/产品/风险/证明上下文

appointment_book = 找可用预约书:
    latest = 按 subject + product_code + custom_id 找最新 AppointmentBook
    if latest 不存在:
        新建 PreAppointmentContract
        新建 AppointmentBook
    else:
        pre = latest.pre_appointment_contract_id 对应记录
        if pre 存在 且 pre.custom_sign_apply_time 为空:
            复用 latest
        else:
            新建 PreAppointmentContract
            新建 AppointmentBook

if body.order_id 存在:
    order_info = 读取原订单预约信息
    appointment_book.apply_amount = order_info.apply_amount
    appointment_book.custom_estimate_wire_date = order_info.custom_estimate_wire_date
    appointment_book.apply_date = order_info.apply_date
    返回预约书 + 原订单预约申购日 + 额度信息
else:
    返回预约书 + 额度信息

后续 /set_order_base_info 成功创建订单后:

appointment_book -> pre_appointment_contract
pre_appointment_contract.custom_sign_apply_time = now

从这一刻起,这份预约书对 POST /appointment_book 来说就是“已结束”,下次进入会新建。

18. 一句话总结

POST /appointment_book 的复用/新建逻辑围绕 AppointmentBook -> PreAppointmentContract 这条关系展开:同主体、同产品、同客户下最新预约书如果关联的 PreAppointmentContract.custom_sign_apply_time 为空,就复用;一旦后续下单成功写入了 custom_sign_apply_time,这份预约书就算结束,下一次进入购买流程会创建新的预约书。

posted on 2026-05-15 10:33  江湖乄夜雨  阅读(21)  评论(0)    收藏  举报