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:
@@ -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",
|
||||
|
||||
@@ -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 + 麻将房 M1–M7
|
||||
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']
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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_id(dwd_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
|
||||
|
||||
# ==========================================================================
|
||||
# 工具方法
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user