feat: P1-P3 全栈集成 — 数据库基础 + DWS 扩展 + 小程序鉴权 + 工程化体系

## P1 数据库基础
- zqyy_app: 创建 auth/biz schema、FDW 连接 etl_feiqiu
- etl_feiqiu: 创建 app schema RLS 视图、商品库存预警表
- 清理 assistant_abolish 残留数据

## P2 ETL/DWS 扩展
- 新增 DWS 助教订单贡献度表 (dws.assistant_order_contribution)
- 新增 assistant_order_contribution_task 任务及 RLS 视图
- member_consumption 增加充值字段、assistant_daily 增加处罚字段
- 更新 ODS/DWD/DWS 任务文档及业务规则文档
- 更新 consistency_checker、flow_runner、task_registry 等核心模块

## P3 小程序鉴权系统
- 新增 xcx_auth 路由/schema(微信登录 + JWT)
- 新增 wechat/role/matching/application 服务层
- zqyy_app 鉴权表迁移 + 角色权限种子数据
- auth/dependencies.py 支持小程序 JWT 鉴权

## 文档与审计
- 新增 DOCUMENTATION-MAP 文档导航
- 新增 7 份 BD_Manual 数据库变更文档
- 更新 DDL 基线快照(etl_feiqiu 6 schema + zqyy_app auth)
- 新增全栈集成审计记录、部署检查清单更新
- 新增 BACKLOG 路线图、FDW→Core 迁移计划

## Kiro 工程化
- 新增 5 个 Spec(P1/P2/P3/全栈集成/核心业务)
- 新增审计自动化脚本(agent_on_stop/build_audit_context/compliance_prescan)
- 新增 6 个 Hook(合规检查/会话日志/提交审计等)
- 新增 doc-map steering 文件

## 运维与测试
- 新增 ops 脚本:迁移验证/API 健康检查/ETL 监控/集成报告
- 新增属性测试:test_dws_contribution / test_auth_system
- 清理过期 export 报告文件
- 更新 .gitignore 排除规则
This commit is contained in:
Neo
2026-02-26 08:03:53 +08:00
parent fafc95e64c
commit b25308c3f4
224 changed files with 17660 additions and 32198 deletions

View File

@@ -13,6 +13,7 @@ DWS层ETL任务模块
from .base_dws_task import BaseDwsTask, TimeLayer, TimeWindow, CourseType, DiscountType
from .assistant_daily_task import AssistantDailyTask
from .assistant_order_contribution_task import AssistantOrderContributionTask
from .assistant_monthly_task import AssistantMonthlyTask
from .assistant_customer_task import AssistantCustomerTask
from .assistant_salary_task import AssistantSalaryTask
@@ -47,6 +48,7 @@ __all__ = [
"DiscountType",
# 助教维度
"AssistantDailyTask",
"AssistantOrderContributionTask",
"AssistantMonthlyTask",
"AssistantCustomerTask",
"AssistantSalaryTask",

View File

@@ -29,12 +29,19 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from decimal import Decimal
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, List, Optional, Set, Tuple
from .base_dws_task import BaseDwsTask, CourseType, TaskContext
# 惩罚区域集合:大厅 A/B/C/S/TV + 麻将房 M1M7
PENALTY_AREAS: Set[str] = {
"A", "B", "C", "S", "TV",
"M1", "M2", "M3", "M4", "M5", "M6", "M7",
}
class AssistantDailyTask(BaseDwsTask):
"""
@@ -93,7 +100,7 @@ class AssistantDailyTask(BaseDwsTask):
def transform(self, extracted: Dict[str, Any], context: TaskContext) -> List[Dict[str, Any]]:
"""
转换数据:按助教+日期聚合
转换数据:按助教+日期聚合,并执行定档折算惩罚检测
"""
service_records = extracted['service_records']
site_id = extracted['site_id']
@@ -108,6 +115,68 @@ class AssistantDailyTask(BaseDwsTask):
service_records,
site_id
)
# ── 定档折算惩罚检测 ──
# 构造重叠检测所需的记录格式
overlap_records = []
for r in service_records:
start_t = r.get("start_use_time")
end_t = r.get("last_use_time")
if start_t is None or end_t is None:
continue
overlap_records.append({
"assistant_id": r.get("assistant_id"),
"table_id": r.get("table_id"),
"table_area": r.get("table_area_name", ""),
"start_time": start_t,
"end_time": end_t,
"service_date": r.get("service_date"),
})
violations = self.detect_overlap_violations(overlap_records, PENALTY_AREAS)
# 将惩罚信息填充到聚合结果
for agg in aggregated:
aid = agg["assistant_id"]
stat_date = agg["stat_date"]
key = (aid, stat_date)
if agg.get("is_exempt"):
# 豁免:不计算惩罚
agg["penalty_minutes"] = Decimal("0")
agg["penalty_reason"] = None
agg["is_exempt"] = True
agg["per_hour_contribution"] = None
elif key in violations:
# 有违规:计算惩罚
# 取第一条违规信息(同一天可能有多条,取最严重的)
v_list = violations[key]
overlap_count = max(v["overlap_count"] for v in v_list)
# per_hour_contribution 需要从台费数据计算
# 此处使用聚合后的 base_ledger_amount 和 base_hours 近似
base_hours = agg.get("base_hours", Decimal("0"))
base_amount = agg.get("base_ledger_amount", Decimal("0"))
if base_hours > 0:
per_hour = base_amount / base_hours / Decimal(str(overlap_count))
else:
per_hour = Decimal("0")
actual_minutes = agg.get("base_hours", Decimal("0")) * Decimal("60")
penalty = self.compute_penalty_minutes(actual_minutes, per_hour)
agg["penalty_minutes"] = penalty
agg["penalty_reason"] = (
f"规则2违规同台桌{overlap_count}名助教重叠挂台,"
f"单人每小时贡献={per_hour:.2f}"
)
agg["is_exempt"] = False
agg["per_hour_contribution"] = per_hour
else:
# 无违规
agg["penalty_minutes"] = Decimal("0")
agg["penalty_reason"] = None
agg["is_exempt"] = False
agg["per_hour_contribution"] = None
return aggregated
@@ -143,6 +212,9 @@ class AssistantDailyTask(BaseDwsTask):
asl.real_use_seconds,
asl.ledger_amount,
asl.ledger_unit_price,
asl.start_use_time,
asl.last_use_time,
asl.table_area_name,
DATE(asl.start_use_time) AS service_date,
COALESCE(ex.is_trash, 0) AS is_trash
FROM dwd.dwd_assistant_service_log asl
@@ -281,6 +353,131 @@ class AssistantDailyTask(BaseDwsTask):
return result
# ==========================================================================
# 定档折算惩罚 — 纯函数(静态方法,不依赖数据库)
# ==========================================================================
@staticmethod
def detect_overlap_violations(
service_records: List[Dict[str, Any]],
penalty_areas: Set[str],
) -> Dict[Tuple[int, date], List[Dict[str, Any]]]:
"""
检测同一台桌同一时间段超过 2 名助教挂台的违规。
输入:
service_records: 服务记录列表,每条需包含
assistant_id, table_id, table_area, start_time, end_time, service_date
penalty_areas: 需要检测的区域集合(如 PENALTY_AREAS
输出:
{(assistant_id, service_date): [violation_info, ...]}
violation_info 包含 table_id, overlap_count, assistant_ids 等
算法:
1. 过滤出属于惩罚区域的记录
2. 按 (table_id, service_date) 分组
3. 对每组用扫描线算法检测最大同时在线助教数
4. 若峰值 > 2标记所有参与助教为违规
"""
# 过滤:仅保留惩罚区域内的记录,且时间信息完整
filtered = []
for r in service_records:
area = r.get("table_area", "")
if area not in penalty_areas:
continue
if r.get("start_time") is None or r.get("end_time") is None:
continue
filtered.append(r)
# 按 (table_id, service_date) 分组
groups: Dict[Tuple[int, date], List[Dict[str, Any]]] = defaultdict(list)
for r in filtered:
key = (r["table_id"], r["service_date"])
groups[key].append(r)
violations: Dict[Tuple[int, date], List[Dict[str, Any]]] = defaultdict(list)
for (table_id, svc_date), records in groups.items():
if len(records) <= 2:
# 不可能超过 2 名助教
continue
# 扫描线:收集所有事件点,检测峰值
events: List[Tuple[Any, int, int]] = [] # (time, +1/-1, assistant_id)
for r in records:
aid = r["assistant_id"]
events.append((r["start_time"], 1, aid))
events.append((r["end_time"], -1, aid))
# 按时间排序;同一时刻先处理 +1开始再处理 -1结束
# 这样"恰好交接"也算重叠
events.sort(key=lambda e: (e[0], -e[1]))
# 扫描:追踪当前在线助教集合
active: Dict[int, int] = defaultdict(int) # assistant_id -> 计数
max_overlap = 0
max_overlap_aids: Set[int] = set()
for t, delta, aid in events:
active[aid] += delta
if active[aid] <= 0:
del active[aid]
current_count = len(active)
if current_count > max_overlap:
max_overlap = current_count
max_overlap_aids = set(active.keys())
elif current_count == max_overlap and current_count > 2:
max_overlap_aids |= set(active.keys())
if max_overlap > 2:
violation_info = {
"table_id": table_id,
"service_date": svc_date,
"overlap_count": max_overlap,
"assistant_ids": max_overlap_aids,
}
# 为每个涉及的助教记录违规
for aid in max_overlap_aids:
violations[(aid, svc_date)].append(violation_info)
return dict(violations)
@staticmethod
def compute_penalty_minutes(
actual_minutes: Decimal,
per_hour_contribution: Decimal,
threshold: Decimal = Decimal("24"),
) -> Decimal:
"""
计算惩罚分钟数(纯函数)。
规则:
- per_hour_contribution >= threshold → 0满额计入
- per_hour_contribution < threshold →
actual_minutes × (1 - per_hour_contribution / threshold)
- per_hour_contribution < 0 → 视为 0防御性编程
结果范围:[0, actual_minutes]
"""
if actual_minutes <= 0:
return Decimal("0")
# 防御性:负值视为 0
phc = max(per_hour_contribution, Decimal("0"))
if phc >= threshold:
return Decimal("0")
# penalty = actual_minutes × (1 - phc / threshold)
ratio = Decimal("1") - phc / threshold
penalty = actual_minutes * ratio
# 确保结果在 [0, actual_minutes] 范围内
penalty = max(Decimal("0"), min(penalty, actual_minutes))
return penalty
# 便于外部导入
__all__ = ['AssistantDailyTask']
__all__ = ['AssistantDailyTask', 'PENALTY_AREAS']

View File

@@ -0,0 +1,542 @@
# -*- coding: utf-8 -*-
"""
助教订单流水四项统计任务
功能说明:
"助教+日期"为粒度,计算每名助教每日的订单流水贡献:
- order_gross_revenue: 订单总流水(台费 + 酒水食品 + 所有助教服务费)
- order_net_revenue: 订单净流水(订单总流水 - 所有助教服务分成)
- time_weighted_revenue: 时效贡献流水(按服务时长折算的个人贡献)
- time_weighted_net_revenue: 时效净贡献(时效贡献流水 - 个人服务分成)
数据来源:
- dwd_settlement_head: 结算主表
- dwd_table_fee_log: 台费明细
- dwd_assistant_service_log: 助教服务记录
目标表:
dws.dws_assistant_order_contribution
更新策略:
- 幂等方式delete-before-insert按日期窗口
核心算法:
时效贡献流水按以下步骤计算:
1. 每张台桌的有效计费时长 = MAX(助教总服务时长, 台桌使用时长)
2. 台费分摊 = table_fee × (个人服务时长 / 有效计费时长)
3. 个人服务费直接计入
4. 酒水食品按助教总时长比例均分
超休/打赏课course_type=BONUS不参与订单级分摊
四项统计均设为该助教个人的服务流水和分成。
作者ETL团队
创建日期2026-02-24
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Any, Dict, List
from .base_dws_task import BaseDwsTask, TaskContext
# =============================================================================
# 数据结构
# =============================================================================
@dataclass
class TableUsage:
"""台桌使用信息"""
table_id: int
table_area: str # 区域名称A/B/C/S/TV/M1-M7 等)
usage_seconds: int # 台桌使用时长(秒)
table_fee: Decimal # 台费/房费
@dataclass
class AssistantService:
"""助教服务记录"""
assistant_id: int
table_id: int
service_seconds: int # 服务时长(秒)
ledger_amount: Decimal # 服务流水(助教收费)
commission: Decimal # 助教分成
skill_id: int
course_type: str # BASE / BONUS / ROOM
nickname: str = "" # 助教昵称(用于输出)
@dataclass
class OrderData:
"""订单聚合数据(一个结算单的完整信息)"""
order_settle_id: int
site_id: int
total_table_fee: Decimal # 台费总额
total_goods_amount: Decimal # 酒水食品总额
tables: List[TableUsage] = field(default_factory=list)
assistants: List[AssistantService] = field(default_factory=list)
stat_date: date | None = None # 订单日期pay_time 的日期部分)
# =============================================================================
# 助教订单流水统计任务
# =============================================================================
class AssistantOrderContributionTask(BaseDwsTask):
"""
助教订单流水四项统计任务
粒度:(site_id, assistant_id, stat_date)
策略delete-before-insert 幂等更新
"""
DATE_COL = "stat_date"
def get_task_code(self) -> str:
return "DWS_ASSISTANT_ORDER_CONTRIBUTION"
def get_target_table(self) -> str:
return "dws_assistant_order_contribution"
def get_primary_keys(self) -> List[str]:
return ["site_id", "assistant_id", "stat_date"]
# =========================================================================
# ETL 主流程(骨架,后续任务实现)
# =========================================================================
def extract(self, context: TaskContext) -> Dict[str, Any]:
"""提取数据:从 DWD 层读取结算、台费和助教服务数据,按订单聚合为 OrderData"""
start_date = context.window_start.date() if hasattr(context.window_start, 'date') else context.window_start
end_date = context.window_end.date() if hasattr(context.window_end, 'date') else context.window_end
site_id = context.store_id
self.logger.info(
"%s: 提取数据,日期范围 %s ~ %s",
self.get_task_code(), start_date, end_date
)
# 1. 提取台桌结账订单的结算主表settle_type=1 为台桌结账)
settlements = self._extract_settlements(site_id, start_date, end_date)
# 2. 提取台费明细
table_fees = self._extract_table_fees(site_id, start_date, end_date)
# 3. 提取助教服务记录(含课程类型映射)
service_logs = self._extract_service_logs(site_id, start_date, end_date)
# 4. 按 order_settle_id 聚合为 OrderData 列表
orders = self._aggregate_to_orders(settlements, table_fees, service_logs)
self.logger.info(
"%s: 提取完成,结算单 %d 条,聚合订单 %d",
self.get_task_code(), len(settlements), len(orders)
)
return {
'orders': orders,
'start_date': start_date,
'end_date': end_date,
'site_id': site_id,
}
def transform(self, extracted: Dict[str, Any], context: TaskContext) -> List[Dict[str, Any]]:
"""转换数据:调用四项统计计算,按 (assistant_id, stat_date) 聚合日度统计"""
orders: List[OrderData] = extracted['orders']
site_id = extracted['site_id']
self.logger.info(
"%s: 转换数据,订单 %d",
self.get_task_code(), len(orders)
)
# 按 (assistant_id, stat_date) 聚合
agg: Dict[tuple, Dict[str, Any]] = {}
for order in orders:
# 跳过无助教服务的订单
if not order.assistants:
continue
# 获取订单日期(从结算主表的 pay_time 推导,存储在 order 中)
stat_date = getattr(order, 'stat_date', None)
if stat_date is None:
continue
# 收集该订单所有参与助教(去重)
assistant_ids = set(a.assistant_id for a in order.assistants)
for aid in assistant_ids:
contribution = self.compute_assistant_contribution(order, aid)
key = (aid, stat_date)
if key not in agg:
# 获取助教昵称(取第一条服务记录的昵称)
nickname = next(
(a.nickname for a in order.assistants if a.assistant_id == aid),
None
)
agg[key] = {
'site_id': site_id,
'tenant_id': order.site_id, # tenant_id 与 site_id 相同
'assistant_id': aid,
'assistant_nickname': nickname,
'stat_date': stat_date,
'order_gross_revenue': Decimal('0'),
'order_net_revenue': Decimal('0'),
'time_weighted_revenue': Decimal('0'),
'time_weighted_net_revenue': Decimal('0'),
'order_count': 0,
'total_service_seconds': 0,
}
rec = agg[key]
rec['order_gross_revenue'] += contribution['order_gross_revenue']
rec['order_net_revenue'] += contribution['order_net_revenue']
rec['time_weighted_revenue'] += contribution['time_weighted_revenue']
rec['time_weighted_net_revenue'] += contribution['time_weighted_net_revenue']
rec['order_count'] += 1
# 累加该助教在该订单中的总服务时长
rec['total_service_seconds'] += sum(
a.service_seconds for a in order.assistants if a.assistant_id == aid
)
result = list(agg.values())
self.logger.info(
"%s: 转换完成,输出 %d 条助教日度统计",
self.get_task_code(), len(result)
)
return result
# load() 使用 BaseDwsTask 默认实现DATE_COL="stat_date"
# =========================================================================
# 数据提取方法
# =========================================================================
def _extract_settlements(
self, site_id: int, start_date: date, end_date: date
) -> List[Dict[str, Any]]:
"""提取台桌结账订单的结算主表
settle_type=1 为台桌结账,包含台费、酒水食品等金额。
"""
sql = """
SELECT
order_settle_id,
site_id,
tenant_id,
table_charge_money,
goods_money,
DATE(pay_time) AS stat_date
FROM dwd.dwd_settlement_head
WHERE site_id = %s
AND settle_type = 1
AND DATE(pay_time) >= %s
AND DATE(pay_time) <= %s
"""
rows = self.db.query(sql, (site_id, start_date, end_date))
return [dict(row) for row in rows] if rows else []
def _extract_table_fees(
self, site_id: int, start_date: date, end_date: date
) -> List[Dict[str, Any]]:
"""提取台费明细
每条记录对应一张台桌在一个订单中的台费信息。
real_table_use_seconds 为台桌实际使用时长。
"""
sql = """
SELECT
tfl.order_settle_id,
tfl.site_table_id AS table_id,
COALESCE(tfl.site_table_area_name, '') AS table_area,
COALESCE(tfl.real_table_use_seconds, 0) AS usage_seconds,
COALESCE(tfl.ledger_amount, 0) AS table_fee
FROM dwd.dwd_table_fee_log tfl
WHERE tfl.site_id = %s
AND DATE(tfl.start_use_time) >= %s
AND DATE(tfl.start_use_time) <= %s
AND COALESCE(tfl.is_delete, 0) = 0
"""
rows = self.db.query(sql, (site_id, start_date, end_date))
return [dict(row) for row in rows] if rows else []
def _extract_service_logs(
self, site_id: int, start_date: date, end_date: date
) -> List[Dict[str, Any]]:
"""提取助教服务记录(含课程类型映射)
通过 LEFT JOIN cfg_skill_type 获取 course_type_code
real_service_money 为助教分成。
"""
sql = """
SELECT
asl.order_settle_id,
asl.site_assistant_id AS assistant_id,
asl.nickname,
asl.site_table_id AS table_id,
COALESCE(asl.income_seconds, 0) AS service_seconds,
COALESCE(asl.ledger_amount, 0) AS ledger_amount,
COALESCE(asl.real_service_money, 0) AS commission,
COALESCE(asl.skill_id, 0) AS skill_id,
COALESCE(cst.course_type_code, 'BASE') AS course_type
FROM dwd.dwd_assistant_service_log asl
LEFT JOIN dws.cfg_skill_type cst
ON asl.skill_id = cst.skill_id
AND cst.is_active = TRUE
WHERE asl.site_id = %s
AND DATE(asl.start_use_time) >= %s
AND DATE(asl.start_use_time) <= %s
AND COALESCE(asl.is_delete, 0) = 0
"""
rows = self.db.query(sql, (site_id, start_date, end_date))
return [dict(row) for row in rows] if rows else []
def _aggregate_to_orders(
self,
settlements: List[Dict[str, Any]],
table_fees: List[Dict[str, Any]],
service_logs: List[Dict[str, Any]],
) -> List[OrderData]:
"""按 order_settle_id 聚合为 OrderData 列表
只保留有助教服务记录的订单(无助教的订单在 transform 中也会跳过)。
"""
from collections import defaultdict
# 按 order_settle_id 索引台费和服务记录
table_fee_map: Dict[int, List[Dict]] = defaultdict(list)
for tf in table_fees:
table_fee_map[tf['order_settle_id']].append(tf)
service_map: Dict[int, List[Dict]] = defaultdict(list)
for sl in service_logs:
service_map[sl['order_settle_id']].append(sl)
orders: List[OrderData] = []
for settle in settlements:
oid = settle['order_settle_id']
svc_list = service_map.get(oid)
# 跳过无助教服务的订单
if not svc_list:
continue
tables = [
TableUsage(
table_id=int(tf['table_id']),
table_area=tf['table_area'],
usage_seconds=int(tf['usage_seconds']),
table_fee=Decimal(str(tf['table_fee'])),
)
for tf in table_fee_map.get(oid, [])
]
assistants = [
AssistantService(
assistant_id=int(sl['assistant_id']),
table_id=int(sl['table_id']),
service_seconds=int(sl['service_seconds']),
ledger_amount=Decimal(str(sl['ledger_amount'])),
commission=Decimal(str(sl['commission'])),
skill_id=int(sl['skill_id']),
course_type=sl['course_type'],
nickname=sl.get('nickname', ''),
)
for sl in svc_list
]
orders.append(OrderData(
order_settle_id=int(oid),
site_id=int(settle['site_id']),
total_table_fee=Decimal(str(settle.get('table_charge_money') or 0)),
total_goods_amount=Decimal(str(settle.get('goods_money') or 0)),
tables=tables,
assistants=assistants,
stat_date=settle.get('stat_date'),
))
return orders
# =========================================================================
# 核心计算(纯函数,不依赖数据库,便于属性测试)
# =========================================================================
@staticmethod
def compute_order_gross_revenue(order: OrderData) -> Decimal:
"""订单总流水 = 台费 + 酒水食品 + 所有助教服务费
每个参与助教获得相同的 order_gross_revenue 值。
"""
total_service_amount = sum(
(a.ledger_amount for a in order.assistants), Decimal('0')
)
return order.total_table_fee + order.total_goods_amount + total_service_amount
@staticmethod
def compute_order_net_revenue(order: OrderData) -> Decimal:
"""订单净流水 = 订单总流水 - 所有助教服务分成
每个参与助教获得相同的 order_net_revenue 值。
"""
gross = AssistantOrderContributionTask.compute_order_gross_revenue(order)
total_commission = sum(
(a.commission for a in order.assistants), Decimal('0')
)
return gross - total_commission
@staticmethod
def compute_time_weighted_revenue(
order: OrderData, assistant_id: int
) -> Decimal:
"""时效贡献流水 = 台费按时长分摊 + 个人服务费 + 酒水食品按时长比例
算法步骤:
1. 每张台桌billable_seconds = MAX(助教总服务时长, 台桌使用时长)
台费分摊 = table_fee × (个人服务时长 / billable_seconds)
2. 个人服务费ledger_amount直接计入
3. 酒水食品按个人总服务时长占所有助教总服务时长的比例均分
超休/打赏课BONUS四项统计均设为个人服务流水和分成
不参与订单级分摊。此逻辑在调用方处理,本方法仅处理常规情况。
边界情况:
- 台桌使用时长为 0 且助教总服务时长也为 0台费分摊 = 0
- 助教总服务时长为 0酒水食品分摊 = 0
"""
# --- 筛选该助教的服务记录(排除 BONUS 类型) ---
my_services = [
a for a in order.assistants
if a.assistant_id == assistant_id and a.course_type != "BONUS"
]
all_non_bonus = [a for a in order.assistants if a.course_type != "BONUS"]
# 如果该助教无非 BONUS 服务记录,返回 0
if not my_services:
return Decimal('0')
# --- 步骤 1台费按时长分摊 ---
table_fee_share = Decimal('0')
for table in order.tables:
# 该台桌上所有助教的服务时长之和
table_total_svc = sum(
a.service_seconds for a in all_non_bonus
if a.table_id == table.table_id
)
# 该助教在该台桌的服务时长
my_table_svc = sum(
a.service_seconds for a in my_services
if a.table_id == table.table_id
)
if my_table_svc == 0:
continue
# 有效计费时长 = MAX(助教总服务时长, 台桌使用时长)
billable_seconds = max(table_total_svc, table.usage_seconds)
if billable_seconds <= 0:
continue
table_fee_share += table.table_fee * Decimal(my_table_svc) / Decimal(billable_seconds)
# --- 步骤 2个人服务费直接计入 ---
personal_service = sum(
(a.ledger_amount for a in my_services), Decimal('0')
)
# --- 步骤 3酒水食品按总时长比例均分 ---
my_total_seconds = sum(a.service_seconds for a in my_services)
all_total_seconds = sum(a.service_seconds for a in all_non_bonus)
if all_total_seconds > 0 and my_total_seconds > 0:
goods_share = order.total_goods_amount * Decimal(my_total_seconds) / Decimal(all_total_seconds)
else:
goods_share = Decimal('0')
return table_fee_share + personal_service + goods_share
@staticmethod
def compute_time_weighted_net_revenue(
time_weighted_revenue: Decimal, assistant_commission: Decimal
) -> Decimal:
"""时效净贡献 = 时效贡献流水 - 个人服务分成"""
return time_weighted_revenue - assistant_commission
@staticmethod
def compute_assistant_contribution(
order: OrderData, assistant_id: int
) -> Dict[str, Decimal]:
"""计算单个助教在单个订单中的四项统计(含 BONUS 特殊处理)
返回字典包含:
- order_gross_revenue
- order_net_revenue
- time_weighted_revenue
- time_weighted_net_revenue
- total_commission该助教个人分成辅助字段
超休/打赏课BONUS四项统计均设为个人服务流水和分成
不参与订单级分摊。
"""
cls = AssistantOrderContributionTask
# 该助教的所有服务记录
my_services = [a for a in order.assistants if a.assistant_id == assistant_id]
if not my_services:
return {
'order_gross_revenue': Decimal('0'),
'order_net_revenue': Decimal('0'),
'time_weighted_revenue': Decimal('0'),
'time_weighted_net_revenue': Decimal('0'),
'total_commission': Decimal('0'),
}
# 分离 BONUS 和非 BONUS 服务
bonus_services = [a for a in my_services if a.course_type == "BONUS"]
normal_services = [a for a in my_services if a.course_type != "BONUS"]
# BONUS 部分:直接用个人流水
bonus_revenue = sum((a.ledger_amount for a in bonus_services), Decimal('0'))
bonus_commission = sum((a.commission for a in bonus_services), Decimal('0'))
if normal_services:
# 有常规服务:按正常逻辑计算
normal_commission = sum((a.commission for a in normal_services), Decimal('0'))
total_commission = normal_commission + bonus_commission
gross = cls.compute_order_gross_revenue(order)
net = cls.compute_order_net_revenue(order)
twr = cls.compute_time_weighted_revenue(order, assistant_id)
# 合成最终值(先算 time_weighted_revenue 再减 total_commission
# 保证 twnr == twr_final - total_commission 精度一致)
twr_final = twr + bonus_revenue
twnr_final = twr_final - total_commission
return {
'order_gross_revenue': gross + bonus_revenue,
'order_net_revenue': net + (bonus_revenue - bonus_commission),
'time_weighted_revenue': twr_final,
'time_weighted_net_revenue': twnr_final,
'total_commission': total_commission,
}
else:
# 纯 BONUS 助教:四项统计均为个人流水
return {
'order_gross_revenue': bonus_revenue,
'order_net_revenue': bonus_revenue - bonus_commission,
'time_weighted_revenue': bonus_revenue,
'time_weighted_net_revenue': bonus_revenue - bonus_commission,
'total_commission': bonus_commission,
}
# 便于外部导入
__all__ = [
'TableUsage',
'AssistantService',
'OrderData',
'AssistantOrderContributionTask',
]

View File

@@ -85,10 +85,14 @@ class MemberConsumptionTask(BaseDwsTask):
# 3. 获取会员卡余额
card_balances = self._extract_card_balances(site_id)
# CHANGE 2025-07-15 | task 4.1: 获取充值统计30/60/90 天窗口)
recharge_stats = self._extract_recharge_stats(site_id, stat_date)
return {
'consumption_stats': consumption_stats,
'member_info': member_info,
'card_balances': card_balances,
'recharge_stats': recharge_stats,
'stat_date': stat_date,
'site_id': site_id
}
@@ -100,6 +104,7 @@ class MemberConsumptionTask(BaseDwsTask):
consumption_stats = extracted['consumption_stats']
member_info = extracted['member_info']
card_balances = extracted['card_balances']
recharge_stats = extracted.get('recharge_stats', {})
stat_date = extracted['stat_date']
site_id = extracted['site_id']
@@ -119,11 +124,20 @@ class MemberConsumptionTask(BaseDwsTask):
memb_info = member_info.get(member_id, {})
balance = card_balances.get(member_id, {})
# CHANGE 2025-07-15 | task 4.2: 合并充值统计,无记录时默认 0
recharge = recharge_stats.get(member_id, {})
# 计算活跃度和客户分层
days_since_last = self._calc_days_since(stat_date, stats.get('last_consume_date'))
customer_tier = self._calculate_customer_tier(stats, days_since_last)
# CHANGE 2025-07-15 | task 4.2: 次均消费 = total_consume_amount / MAX(total_visit_count, 1)
total_consume_amount = self.safe_decimal(stats.get('total_consume_amount', 0))
total_visit_count = self.safe_int(stats.get('total_visit_count', 0))
avg_ticket_amount = (
total_consume_amount / max(total_visit_count, 1)
).quantize(Decimal('0.01'))
record = {
'site_id': site_id,
'tenant_id': self.config.get("app.tenant_id", site_id),
@@ -137,8 +151,8 @@ class MemberConsumptionTask(BaseDwsTask):
# 全量累计统计
'first_consume_date': stats.get('first_consume_date'),
'last_consume_date': stats.get('last_consume_date'),
'total_visit_count': self.safe_int(stats.get('total_visit_count', 0)),
'total_consume_amount': self.safe_decimal(stats.get('total_consume_amount', 0)),
'total_visit_count': total_visit_count,
'total_consume_amount': total_consume_amount,
'total_recharge_amount': self.safe_decimal(memb_info.get('recharge_money_sum', 0)),
'total_table_fee': self.safe_decimal(stats.get('total_table_fee', 0)),
'total_goods_amount': self.safe_decimal(stats.get('total_goods_amount', 0)),
@@ -156,6 +170,15 @@ class MemberConsumptionTask(BaseDwsTask):
'consume_amount_30d': self.safe_decimal(stats.get('consume_amount_30d', 0)),
'consume_amount_60d': self.safe_decimal(stats.get('consume_amount_60d', 0)),
'consume_amount_90d': self.safe_decimal(stats.get('consume_amount_90d', 0)),
# 充值窗口统计30/60/90 天)
'recharge_count_30d': self.safe_int(recharge.get('count_30d', 0)),
'recharge_count_60d': self.safe_int(recharge.get('count_60d', 0)),
'recharge_count_90d': self.safe_int(recharge.get('count_90d', 0)),
'recharge_amount_30d': self.safe_decimal(recharge.get('amount_30d', 0)),
'recharge_amount_60d': self.safe_decimal(recharge.get('amount_60d', 0)),
'recharge_amount_90d': self.safe_decimal(recharge.get('amount_90d', 0)),
# 次均消费
'avg_ticket_amount': avg_ticket_amount,
# 卡余额
'cash_card_balance': self.safe_decimal(balance.get('cash_balance', 0)),
'gift_card_balance': self.safe_decimal(balance.get('gift_balance', 0)),
@@ -259,13 +282,14 @@ class MemberConsumptionTask(BaseDwsTask):
) AS birthday
FROM dwd.dim_member m
WHERE m.member_id IN (
SELECT DISTINCT tenant_member_id
SELECT DISTINCT member_id
FROM dwd.dwd_settlement_head
WHERE site_id = %s
AND tenant_member_id IS NOT NULL
AND tenant_member_id != 0
AND member_id IS NOT NULL
AND member_id != 0
) AND m.scd2_is_current = 1
"""
# CHANGE 2026-02-24 | 修复列名tenant_member_id → member_iddwd_settlement_head 无 tenant_member_id 列)
sql_fallback = """
SELECT
member_id,
@@ -277,16 +301,18 @@ class MemberConsumptionTask(BaseDwsTask):
birthday
FROM dwd.dim_member
WHERE member_id IN (
SELECT DISTINCT tenant_member_id
SELECT DISTINCT member_id
FROM dwd.dwd_settlement_head
WHERE site_id = %s
AND tenant_member_id IS NOT NULL
AND tenant_member_id != 0
AND member_id IS NOT NULL
AND member_id != 0
) AND scd2_is_current = 1
"""
try:
rows = self.db.query(sql_with_fdw, (site_id,))
except Exception as exc:
# CHANGE [2026-02-24] FDW 查询失败后事务处于 failed 状态,必须先 rollback 再执行 fallback
self.db.rollback()
# FDW 连接失败,降级为仅使用 dim_member.birthday
self.logger.warning(
"%s: FDW 读取 member_birthday_manual 失败,降级为 dim_member.birthday — %s",
@@ -352,6 +378,55 @@ class MemberConsumptionTask(BaseDwsTask):
return result
# CHANGE 2025-07-15 | task 4.1: 新增充值统计提取方法
def _extract_recharge_stats(
self,
site_id: int,
stat_date: date,
) -> Dict[int, Dict[str, Any]]:
"""
从 dwd.dwd_recharge_order 提取 30/60/90 天充值统计
返回: {member_id: {count_30d, count_60d, count_90d,
amount_30d, amount_60d, amount_90d}}
"""
sql = """
SELECT
member_id,
COUNT(CASE WHEN DATE(pay_time) >= %s - INTERVAL '29 days' THEN 1 END) AS count_30d,
COUNT(CASE WHEN DATE(pay_time) >= %s - INTERVAL '59 days' THEN 1 END) AS count_60d,
COUNT(CASE WHEN DATE(pay_time) >= %s - INTERVAL '89 days' THEN 1 END) AS count_90d,
COALESCE(SUM(CASE WHEN DATE(pay_time) >= %s - INTERVAL '29 days' THEN pay_amount ELSE 0 END), 0) AS amount_30d,
COALESCE(SUM(CASE WHEN DATE(pay_time) >= %s - INTERVAL '59 days' THEN pay_amount ELSE 0 END), 0) AS amount_60d,
COALESCE(SUM(CASE WHEN DATE(pay_time) >= %s - INTERVAL '89 days' THEN pay_amount ELSE 0 END), 0) AS amount_90d
FROM dwd.dwd_recharge_order
WHERE site_id = %s
AND member_id IS NOT NULL
AND member_id != 0
AND pay_time IS NOT NULL
AND DATE(pay_time) <= %s
GROUP BY member_id
"""
params = (
stat_date, stat_date, stat_date,
stat_date, stat_date, stat_date,
site_id, stat_date,
)
rows = self.db.query(sql, params)
result: Dict[int, Dict[str, Any]] = {}
for row in (rows or []):
rd = dict(row)
result[rd['member_id']] = {
'count_30d': rd.get('count_30d', 0),
'count_60d': rd.get('count_60d', 0),
'count_90d': rd.get('count_90d', 0),
'amount_30d': self.safe_decimal(rd.get('amount_30d', 0)),
'amount_60d': self.safe_decimal(rd.get('amount_60d', 0)),
'amount_90d': self.safe_decimal(rd.get('amount_90d', 0)),
}
return result
# ==========================================================================
# 工具方法
# ==========================================================================

View File

@@ -351,6 +351,8 @@ class MemberVisitTask(BaseDwsTask):
try:
rows = self.db.query(sql_with_fdw, (site_id,))
except Exception as exc:
# CHANGE [2026-02-24] FDW 查询失败后事务处于 failed 状态,必须先 rollback 再执行 fallback
self.db.rollback()
# FDW 连接失败,降级为仅使用 dim_member.birthday
self.logger.warning(
"%s: FDW 读取 member_birthday_manual 失败,降级为 dim_member.birthday — %s",