Compare commits
4 Commits
7b2d6a9fbb
...
42171c01db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42171c01db | ||
|
|
279eceaa95 | ||
|
|
9e146c5ef0 | ||
|
|
3cf39b4664 |
@@ -4,6 +4,7 @@
|
|||||||
from .base import BaseCleaner
|
from .base import BaseCleaner
|
||||||
from .alipay import AlipayCleaner
|
from .alipay import AlipayCleaner
|
||||||
from .wechat import WechatCleaner
|
from .wechat import WechatCleaner
|
||||||
|
from .jd import JDCleaner
|
||||||
|
|
||||||
__all__ = ['BaseCleaner', 'AlipayCleaner', 'WechatCleaner']
|
__all__ = ['BaseCleaner', 'AlipayCleaner', 'WechatCleaner', 'JDCleaner']
|
||||||
|
|
||||||
|
|||||||
370
analyzer/cleaners/jd.py
Normal file
370
analyzer/cleaners/jd.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
"""
|
||||||
|
京东白条账单清理模块
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
BaseCleaner, parse_amount, format_amount,
|
||||||
|
is_in_date_range, create_arg_parser
|
||||||
|
)
|
||||||
|
from category import infer_category
|
||||||
|
|
||||||
|
|
||||||
|
# 加载京东专属分类配置
|
||||||
|
JD_CONFIG_FILE = Path(__file__).parent.parent / "config" / "category_jd.yaml"
|
||||||
|
|
||||||
|
def load_jd_config():
|
||||||
|
"""加载京东分类配置"""
|
||||||
|
with open(JD_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
_jd_config = load_jd_config()
|
||||||
|
|
||||||
|
|
||||||
|
def infer_jd_category(merchant: str, product: str, original_category: str) -> tuple[str, bool, int]:
|
||||||
|
"""
|
||||||
|
根据京东账单的商户名称、商品说明和原分类推断统一分类
|
||||||
|
|
||||||
|
Args:
|
||||||
|
merchant: 商户名称(如"京东外卖"、"京东平台商户")
|
||||||
|
product: 交易说明/商品说明
|
||||||
|
original_category: 京东原始分类(如"食品酒饮"、"数码电器")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(分类名称, 是否确定, 复核等级)
|
||||||
|
|
||||||
|
复核等级:
|
||||||
|
0 = 无需复核(商户映射或原分类映射成功,高置信度)
|
||||||
|
1 = 低优先级复核(通用关键词匹配成功,需确认)
|
||||||
|
2 = 高优先级复核(全部匹配失败或未知分类,需人工分类)
|
||||||
|
"""
|
||||||
|
# 1. 先检查商户名称直接映射(如"京东外卖" -> "餐饮美食")
|
||||||
|
merchant_mapping = _jd_config.get("商户映射", {})
|
||||||
|
for merchant_key, category in merchant_mapping.items():
|
||||||
|
if merchant_key in merchant:
|
||||||
|
return category, True, 0 # 商户映射,无需复核
|
||||||
|
|
||||||
|
# 2. 尝试直接映射京东原分类
|
||||||
|
category_mapping = _jd_config.get("分类映射", {})
|
||||||
|
|
||||||
|
# 处理多分类情况(如"食品酒饮 其他网购")
|
||||||
|
original_cats = original_category.split() if original_category else []
|
||||||
|
for orig_cat in original_cats:
|
||||||
|
if orig_cat in category_mapping:
|
||||||
|
mapped = category_mapping[orig_cat]
|
||||||
|
if mapped: # 非空映射 → 使用映射结果
|
||||||
|
return mapped, True, 0 # 原分类映射,无需复核
|
||||||
|
# 空映射(如"其他"→"")→ 继续检查下一个原分类或进入关键词匹配
|
||||||
|
else:
|
||||||
|
# 未知分类(不在映射表中)→ 保留原分类,HIGH 复核
|
||||||
|
return orig_cat, True, 2
|
||||||
|
|
||||||
|
# 3. 使用通用分类推断(已包含京东平台商户关键词)
|
||||||
|
category, is_certain = infer_category(merchant, product, "支出")
|
||||||
|
if is_certain:
|
||||||
|
return category, True, 1 # 关键词匹配,低优先级复核
|
||||||
|
|
||||||
|
# 4. 返回默认分类
|
||||||
|
return _jd_config.get("默认分类", "其他支出"), False, 2 # 全部失败,高优先级复核
|
||||||
|
|
||||||
|
|
||||||
|
# 与支付宝/微信对齐的表头(包含"复核等级"字段)
|
||||||
|
ALIGNED_HEADER = [
|
||||||
|
"交易时间", "交易分类", "交易对方", "对方账号", "商品说明",
|
||||||
|
"收/支", "金额", "收/付款方式", "交易状态", "交易订单号", "商家订单号", "备注", "复核等级"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class JDCleaner(BaseCleaner):
|
||||||
|
"""京东白条账单清理器"""
|
||||||
|
|
||||||
|
def clean(self) -> None:
|
||||||
|
"""执行清理"""
|
||||||
|
self.print_header()
|
||||||
|
|
||||||
|
# 读取数据,跳过京东导出文件的头部信息
|
||||||
|
with open(self.input_file, "r", encoding="utf-8") as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
header = None
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
# 跳过空行
|
||||||
|
if not row or not row[0].strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 清理每个字段的 tab 字符
|
||||||
|
row = [cell.strip().replace('\t', '') for cell in row]
|
||||||
|
|
||||||
|
# 查找实际的CSV头部行(包含"交易时间"和"商户名称")
|
||||||
|
if header is None:
|
||||||
|
if len(row) >= 2 and "交易时间" in row[0] and "商户名称" in row[1]:
|
||||||
|
header = row
|
||||||
|
continue
|
||||||
|
# 跳过头部信息行
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 收集数据行
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
# 确保找到了有效的头部
|
||||||
|
if header is None:
|
||||||
|
raise ValueError("无法找到有效的京东账单表头(需包含'交易时间'和'商户名称'列)")
|
||||||
|
|
||||||
|
self.stats["original_count"] = len(rows)
|
||||||
|
print(f"原始数据行数: {len(rows)}")
|
||||||
|
|
||||||
|
# 第一步:按日期范围筛选
|
||||||
|
rows_filtered = [
|
||||||
|
row for row in rows
|
||||||
|
if row and is_in_date_range(row[0], self.start_date, self.end_date)
|
||||||
|
]
|
||||||
|
self.stats["filtered_count"] = len(rows_filtered)
|
||||||
|
|
||||||
|
date_desc = f"{self.start_date} ~ {self.end_date}" if self.start_date or self.end_date else "全部"
|
||||||
|
print(f"筛选后数据行数: {len(rows_filtered)} ({date_desc})")
|
||||||
|
|
||||||
|
# 第二步:分离退款和支出条目(过滤掉"不计收支")
|
||||||
|
refund_rows = []
|
||||||
|
expense_rows = []
|
||||||
|
skipped_count = 0 # 不计收支(还款、冻结等)
|
||||||
|
|
||||||
|
for row in rows_filtered:
|
||||||
|
if len(row) < 7:
|
||||||
|
continue
|
||||||
|
|
||||||
|
income_expense = row[6].strip() # 收/支 列
|
||||||
|
transaction_desc = row[2].strip() # 交易说明
|
||||||
|
status = row[5].strip() # 交易状态
|
||||||
|
|
||||||
|
# 过滤掉"不计收支"记录(还款、冻结、预授权等)
|
||||||
|
if income_expense == "不计收支":
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 退款判断:交易说明以"退款-"开头 或 状态包含"退款成功"
|
||||||
|
if transaction_desc.startswith("退款-") or "退款" in status:
|
||||||
|
refund_rows.append(row)
|
||||||
|
elif income_expense == "支出":
|
||||||
|
expense_rows.append(row)
|
||||||
|
|
||||||
|
print(f"退款条目数: {len(refund_rows)}")
|
||||||
|
print(f"支出条目数: {len(expense_rows)}")
|
||||||
|
print(f"不计收支过滤: {skipped_count} 条(还款/冻结等)")
|
||||||
|
|
||||||
|
# 第三步:处理退款
|
||||||
|
# 京东账单特点:已全额退款的记录金额会显示为 "179.00(已全额退款)"
|
||||||
|
final_expense_rows = self._process_expenses(expense_rows, refund_rows)
|
||||||
|
|
||||||
|
print(f"\n处理结果:")
|
||||||
|
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
||||||
|
print(f" 部分退款调整: {self.stats['partially_refunded']} 条")
|
||||||
|
if self.stats.get("zero_amount", 0) > 0:
|
||||||
|
print(f" 0元记录过滤: {self.stats['zero_amount']} 条")
|
||||||
|
print(f" 最终保留行数: {len(final_expense_rows)}")
|
||||||
|
|
||||||
|
# 第四步:转换为对齐格式并重新分类
|
||||||
|
aligned_rows = [self._convert_and_reclassify(row_data) for row_data in final_expense_rows]
|
||||||
|
|
||||||
|
# 按时间排序(最新在前)
|
||||||
|
aligned_rows.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
# 统计复核数量
|
||||||
|
review_high_count = sum(1 for row in aligned_rows if row[-1] == "HIGH")
|
||||||
|
|
||||||
|
self.stats["final_count"] = len(aligned_rows)
|
||||||
|
if review_high_count > 0:
|
||||||
|
print(f" 高优先级复核: {review_high_count} 条(无法判断)")
|
||||||
|
|
||||||
|
# 写入文件
|
||||||
|
self.write_output(ALIGNED_HEADER, aligned_rows)
|
||||||
|
|
||||||
|
print(f"\n清理后的数据已保存到: {self.output_file}")
|
||||||
|
|
||||||
|
# 统计支出
|
||||||
|
self._print_expense_summary(aligned_rows)
|
||||||
|
|
||||||
|
def _parse_jd_amount(self, amount_str: str) -> tuple[Decimal, bool]:
|
||||||
|
"""
|
||||||
|
解析京东账单金额
|
||||||
|
|
||||||
|
京东金额格式特点:
|
||||||
|
- 普通金额: "179.00"
|
||||||
|
- 全额退款: "179.00(已全额退款)"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(金额, 是否已全额退款)
|
||||||
|
"""
|
||||||
|
amount_str = amount_str.strip()
|
||||||
|
|
||||||
|
# 检查是否包含"已全额退款"
|
||||||
|
if "(已全额退款)" in amount_str or "(已全额退款)" in amount_str:
|
||||||
|
# 提取金额部分
|
||||||
|
amount_part = re.sub(r'[((]已全额退款[))]', '', amount_str)
|
||||||
|
return parse_amount(amount_part), True
|
||||||
|
|
||||||
|
return parse_amount(amount_str), False
|
||||||
|
|
||||||
|
def _process_expenses(self, expense_rows: list, refund_rows: list) -> list:
|
||||||
|
"""
|
||||||
|
处理支出记录
|
||||||
|
|
||||||
|
京东账单特点:
|
||||||
|
1. 已全额退款的记录金额显示为 "金额(已全额退款)"
|
||||||
|
2. 部分退款可能有单独的退款记录
|
||||||
|
"""
|
||||||
|
# 构建退款索引(按订单号)
|
||||||
|
order_refunds = {}
|
||||||
|
for row in refund_rows:
|
||||||
|
if len(row) >= 9:
|
||||||
|
order_no = row[8].strip() # 交易订单号
|
||||||
|
amount = parse_amount(row[3]) # 金额
|
||||||
|
if order_no:
|
||||||
|
if order_no not in order_refunds:
|
||||||
|
order_refunds[order_no] = Decimal("0")
|
||||||
|
order_refunds[order_no] += amount
|
||||||
|
print(f" 退款记录: {row[0]} | {row[1]} | {amount}元")
|
||||||
|
|
||||||
|
final_rows = []
|
||||||
|
|
||||||
|
for row in expense_rows:
|
||||||
|
if len(row) < 9:
|
||||||
|
continue
|
||||||
|
|
||||||
|
order_no = row[8].strip() # 交易订单号
|
||||||
|
amount, is_fully_refunded = self._parse_jd_amount(row[3])
|
||||||
|
|
||||||
|
# 情况1:金额已标注"已全额退款"
|
||||||
|
if is_fully_refunded:
|
||||||
|
self.stats["fully_refunded"] += 1
|
||||||
|
desc = row[2][:25] if len(row[2]) > 25 else row[2]
|
||||||
|
print(f" 全额退款删除: {row[0]} | {row[1]} | {desc}... | {row[3]}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 情况2:检查是否有对应的退款记录
|
||||||
|
refund_amount = order_refunds.get(order_no, Decimal("0"))
|
||||||
|
if refund_amount > 0:
|
||||||
|
if refund_amount >= amount:
|
||||||
|
# 全额退款
|
||||||
|
self.stats["fully_refunded"] += 1
|
||||||
|
desc = row[2][:25] if len(row[2]) > 25 else row[2]
|
||||||
|
print(f" 全额退款删除: {row[0]} | {row[1]} | {desc}... | 原{amount}元")
|
||||||
|
else:
|
||||||
|
# 部分退款
|
||||||
|
remaining = amount - refund_amount
|
||||||
|
new_row = row.copy()
|
||||||
|
new_row[3] = format_amount(remaining)
|
||||||
|
remark = f"原金额{amount}元,退款{refund_amount}元"
|
||||||
|
|
||||||
|
final_rows.append((new_row, remark))
|
||||||
|
self.stats["partially_refunded"] += 1
|
||||||
|
print(f" 部分退款: {row[0]} | {row[1]} | 原{amount}元 -> {format_amount(remaining)}元")
|
||||||
|
else:
|
||||||
|
# 无退款,正常记录
|
||||||
|
if amount > 0:
|
||||||
|
final_rows.append((row, None))
|
||||||
|
else:
|
||||||
|
self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1
|
||||||
|
|
||||||
|
return final_rows
|
||||||
|
|
||||||
|
def _convert_and_reclassify(self, row_tuple: tuple) -> list:
|
||||||
|
"""
|
||||||
|
转换为对齐格式并重新分类
|
||||||
|
|
||||||
|
京东原始字段:
|
||||||
|
0: 交易时间, 1: 商户名称, 2: 交易说明, 3: 金额,
|
||||||
|
4: 收/付款方式, 5: 交易状态, 6: 收/支, 7: 交易分类,
|
||||||
|
8: 交易订单号, 9: 商家订单号, 10: 备注
|
||||||
|
|
||||||
|
对齐后字段:
|
||||||
|
交易时间, 交易分类, 交易对方, 对方账号, 商品说明,
|
||||||
|
收/支, 金额, 收/付款方式, 交易状态, 交易订单号, 商家订单号, 备注, 复核等级
|
||||||
|
"""
|
||||||
|
if isinstance(row_tuple, tuple):
|
||||||
|
row, remark = row_tuple
|
||||||
|
else:
|
||||||
|
row, remark = row_tuple, None
|
||||||
|
|
||||||
|
transaction_time = row[0]
|
||||||
|
merchant = row[1] # 商户名称
|
||||||
|
product = row[2] # 交易说明
|
||||||
|
amount, _ = self._parse_jd_amount(row[3])
|
||||||
|
payment_method = row[4] if len(row) > 4 else ""
|
||||||
|
status = row[5] if len(row) > 5 else ""
|
||||||
|
income_expense = row[6] if len(row) > 6 else "支出"
|
||||||
|
original_category = row[7] if len(row) > 7 else ""
|
||||||
|
order_no = row[8] if len(row) > 8 else ""
|
||||||
|
merchant_order_no = row[9] if len(row) > 9 else ""
|
||||||
|
final_remark = remark if remark else (row[10] if len(row) > 10 else "/")
|
||||||
|
|
||||||
|
# 使用京东专属分类推断
|
||||||
|
category, is_certain, review_level = infer_jd_category(merchant, product, original_category)
|
||||||
|
|
||||||
|
# 复核等级映射: 0=空, 1=LOW, 2=HIGH
|
||||||
|
review_marks = {0: "", 1: "LOW", 2: "HIGH"}
|
||||||
|
review_mark = review_marks.get(review_level, "")
|
||||||
|
|
||||||
|
return [
|
||||||
|
transaction_time,
|
||||||
|
category,
|
||||||
|
merchant,
|
||||||
|
"/", # 对方账号(京东无此字段)
|
||||||
|
product,
|
||||||
|
income_expense,
|
||||||
|
format_amount(amount),
|
||||||
|
payment_method,
|
||||||
|
status,
|
||||||
|
order_no,
|
||||||
|
merchant_order_no,
|
||||||
|
final_remark,
|
||||||
|
review_mark
|
||||||
|
]
|
||||||
|
|
||||||
|
def reclassify(self, rows: list) -> list:
|
||||||
|
"""
|
||||||
|
重新分类京东账单
|
||||||
|
|
||||||
|
京东账单在 _convert_and_reclassify 中已完成分类
|
||||||
|
此方法为接口兼容保留
|
||||||
|
"""
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _print_expense_summary(self, expense_rows: list):
|
||||||
|
"""打印支出统计"""
|
||||||
|
total = Decimal("0")
|
||||||
|
categories = {}
|
||||||
|
|
||||||
|
for row in expense_rows:
|
||||||
|
if row[5] == "支出":
|
||||||
|
amt = Decimal(row[6])
|
||||||
|
total += amt
|
||||||
|
cat = row[1]
|
||||||
|
categories[cat] = categories.get(cat, Decimal("0")) + amt
|
||||||
|
|
||||||
|
print(f"清理后支出总额: ¥{total}")
|
||||||
|
print("\n=== 按分类统计 ===")
|
||||||
|
for cat, amt in sorted(categories.items(), key=lambda x: -x[1]):
|
||||||
|
print(f" {cat}: ¥{amt}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""命令行入口"""
|
||||||
|
parser = create_arg_parser("清理京东白条账单数据")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
from .base import compute_date_range
|
||||||
|
|
||||||
|
cleaner = JDCleaner(args.input_file, args.output_file)
|
||||||
|
start_date, end_date = compute_date_range(args)
|
||||||
|
cleaner.set_date_range(start_date, end_date)
|
||||||
|
cleaner.clean()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -84,6 +84,40 @@
|
|||||||
- 供暖
|
- 供暖
|
||||||
- 暖气
|
- 暖气
|
||||||
|
|
||||||
|
# 宠物用品
|
||||||
|
宠物用品:
|
||||||
|
- 宠物
|
||||||
|
- 猫咪
|
||||||
|
- 狗
|
||||||
|
- 猫粮
|
||||||
|
- 狗粮
|
||||||
|
- 猫砂
|
||||||
|
- 喂水
|
||||||
|
- 猫零食
|
||||||
|
- 犬猫
|
||||||
|
|
||||||
|
# 数码电器
|
||||||
|
数码电器:
|
||||||
|
- 饮水机
|
||||||
|
- 净水
|
||||||
|
- 制冰
|
||||||
|
- nas
|
||||||
|
- 存储
|
||||||
|
- 硬盘
|
||||||
|
- 电脑
|
||||||
|
- 手机
|
||||||
|
- 平板
|
||||||
|
- 电器
|
||||||
|
- 小家电
|
||||||
|
- 充电
|
||||||
|
- 数据线
|
||||||
|
- 路由器
|
||||||
|
- 音箱
|
||||||
|
- 耳机
|
||||||
|
- 键盘
|
||||||
|
- 鼠标
|
||||||
|
- 显示器
|
||||||
|
|
||||||
# 运动健身
|
# 运动健身
|
||||||
运动健身:
|
运动健身:
|
||||||
- 健身
|
- 健身
|
||||||
@@ -113,6 +147,9 @@
|
|||||||
- 电影
|
- 电影
|
||||||
- 游戏
|
- 游戏
|
||||||
- 娱乐
|
- 娱乐
|
||||||
|
- 书
|
||||||
|
- 图书
|
||||||
|
- 文娱
|
||||||
- 旅游
|
- 旅游
|
||||||
- 景区
|
- 景区
|
||||||
- 门票
|
- 门票
|
||||||
@@ -157,6 +194,15 @@
|
|||||||
- 妍丽 # AFIONA妍丽美妆店
|
- 妍丽 # AFIONA妍丽美妆店
|
||||||
- 屈臣氏
|
- 屈臣氏
|
||||||
- 丝芙兰
|
- 丝芙兰
|
||||||
|
- 保鲜盒
|
||||||
|
- 收纳
|
||||||
|
- 厨房
|
||||||
|
- 清洁
|
||||||
|
- 洗衣
|
||||||
|
- 纸巾
|
||||||
|
- 毛巾
|
||||||
|
- 床品
|
||||||
|
- 家居
|
||||||
|
|
||||||
# 餐饮美食
|
# 餐饮美食
|
||||||
餐饮美食:
|
餐饮美食:
|
||||||
|
|||||||
48
analyzer/config/category_jd.yaml
Normal file
48
analyzer/config/category_jd.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 京东账单分类映射配置
|
||||||
|
# 将京东原始分类转换为统一分类
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 京东原始分类 -> 统一分类映射
|
||||||
|
# 京东账单中的"交易分类"字段可能包含以下值:
|
||||||
|
# - 余额、小金库、白条:财务操作(已在清洗时过滤)
|
||||||
|
# - 其他、其他网购、网购:需要根据商品说明进一步判断
|
||||||
|
# - 食品酒饮:餐饮美食
|
||||||
|
# - 数码电器、电脑办公:数码电器
|
||||||
|
# - 日用百货:日用百货
|
||||||
|
# - 图书文娱:文化休闲
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
分类映射:
|
||||||
|
# 直接映射(京东分类 -> 统一分类)
|
||||||
|
食品酒饮: 餐饮美食
|
||||||
|
数码电器: 数码电器
|
||||||
|
电脑办公: 数码电器
|
||||||
|
日用百货: 日用百货
|
||||||
|
图书文娱: 文化休闲
|
||||||
|
|
||||||
|
# 需要进一步判断的分类(返回空字符串,由关键词推断)
|
||||||
|
其他: ""
|
||||||
|
其他网购: ""
|
||||||
|
网购: ""
|
||||||
|
|
||||||
|
# 财务类(通常已被过滤,但以防万一)
|
||||||
|
余额: ""
|
||||||
|
小金库: ""
|
||||||
|
白条: ""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 商户名称 -> 统一分类映射
|
||||||
|
# 根据商户名称直接映射分类,无需关键词匹配
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
商户映射:
|
||||||
|
京东外卖: 餐饮美食
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 默认分类
|
||||||
|
# 当无法匹配任何规则时使用
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
默认分类: 其他支出
|
||||||
@@ -49,7 +49,7 @@ def detect_bill_type_from_content(content: str, filename: str = "") -> str:
|
|||||||
从内容和文件名检测账单类型
|
从内容和文件名检测账单类型
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
'alipay', 'wechat', 或 ''
|
'alipay', 'wechat', 'jd', 或 ''
|
||||||
"""
|
"""
|
||||||
# 从文件名检测
|
# 从文件名检测
|
||||||
filename_lower = filename.lower()
|
filename_lower = filename.lower()
|
||||||
@@ -57,6 +57,8 @@ def detect_bill_type_from_content(content: str, filename: str = "") -> str:
|
|||||||
return 'alipay'
|
return 'alipay'
|
||||||
if '微信' in filename or 'wechat' in filename_lower:
|
if '微信' in filename or 'wechat' in filename_lower:
|
||||||
return 'wechat'
|
return 'wechat'
|
||||||
|
if '京东' in filename or 'jd' in filename_lower:
|
||||||
|
return 'jd'
|
||||||
|
|
||||||
# 从内容检测
|
# 从内容检测
|
||||||
# 支付宝特征: 有 "交易分类" 和 "对方账号" 列
|
# 支付宝特征: 有 "交易分类" 和 "对方账号" 列
|
||||||
@@ -67,6 +69,12 @@ def detect_bill_type_from_content(content: str, filename: str = "") -> str:
|
|||||||
if '交易类型' in content and '金额(元)' in content:
|
if '交易类型' in content and '金额(元)' in content:
|
||||||
return 'wechat'
|
return 'wechat'
|
||||||
|
|
||||||
|
# 京东特征: 有 "商户名称" 和 "交易说明" 列,或头部包含 "京东账号名"
|
||||||
|
if '商户名称' in content and '交易说明' in content:
|
||||||
|
return 'jd'
|
||||||
|
if '京东账号名' in content:
|
||||||
|
return 'jd'
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ if sys.stdout.encoding != 'utf-8':
|
|||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||||
|
|
||||||
from cleaners.base import compute_date_range_from_values
|
from cleaners.base import compute_date_range_from_values
|
||||||
from cleaners import AlipayCleaner, WechatCleaner
|
from cleaners import AlipayCleaner, WechatCleaner, JDCleaner
|
||||||
from category import infer_category, get_all_categories, get_all_income_categories
|
from category import infer_category, get_all_categories, get_all_income_categories
|
||||||
from converter import convert_bill_file
|
from converter import convert_bill_file
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class CleanRequest(BaseModel):
|
|||||||
start: Optional[str] = None
|
start: Optional[str] = None
|
||||||
end: Optional[str] = None
|
end: Optional[str] = None
|
||||||
format: Optional[str] = "csv"
|
format: Optional[str] = "csv"
|
||||||
bill_type: Optional[str] = "auto" # auto, alipay, wechat
|
bill_type: Optional[str] = "auto" # auto, alipay, wechat, jd
|
||||||
|
|
||||||
|
|
||||||
class CleanResponse(BaseModel):
|
class CleanResponse(BaseModel):
|
||||||
@@ -90,7 +90,7 @@ def detect_bill_type(filepath: str) -> str | None:
|
|||||||
检测账单类型
|
检测账单类型
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
'alipay' | 'wechat' | None
|
'alipay' | 'wechat' | 'jd' | None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(filepath, "r", encoding="utf-8") as f:
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
@@ -107,6 +107,14 @@ def detect_bill_type(filepath: str) -> str | None:
|
|||||||
if "交易类型" in line and "金额(元)" in line:
|
if "交易类型" in line and "金额(元)" in line:
|
||||||
return "wechat"
|
return "wechat"
|
||||||
|
|
||||||
|
# 京东特征:表头包含 "商户名称" 和 "交易说明"
|
||||||
|
if "商户名称" in line and "交易说明" in line:
|
||||||
|
return "jd"
|
||||||
|
|
||||||
|
# 京东特征:头部信息包含 "京东账号名"
|
||||||
|
if "京东账号名" in line:
|
||||||
|
return "jd"
|
||||||
|
|
||||||
# 数据行特征
|
# 数据行特征
|
||||||
if line.startswith("202"):
|
if line.startswith("202"):
|
||||||
if "¥" in line:
|
if "¥" in line:
|
||||||
@@ -155,14 +163,16 @@ def do_clean(
|
|||||||
try:
|
try:
|
||||||
if bill_type == "alipay":
|
if bill_type == "alipay":
|
||||||
cleaner = AlipayCleaner(input_path, output_path, output_format)
|
cleaner = AlipayCleaner(input_path, output_path, output_format)
|
||||||
|
elif bill_type == "jd":
|
||||||
|
cleaner = JDCleaner(input_path, output_path, output_format)
|
||||||
else:
|
else:
|
||||||
cleaner = WechatCleaner(input_path, output_path, output_format)
|
cleaner = WechatCleaner(input_path, output_path, output_format)
|
||||||
|
|
||||||
cleaner.set_date_range(start_date, end_date)
|
cleaner.set_date_range(start_date, end_date)
|
||||||
cleaner.clean()
|
cleaner.clean()
|
||||||
|
|
||||||
type_names = {"alipay": "支付宝", "wechat": "微信"}
|
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
|
||||||
return True, bill_type, f"✅ {type_names[bill_type]}账单清洗完成"
|
return True, bill_type, f"✅ {type_names.get(bill_type, bill_type)}账单清洗完成"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, bill_type, f"清洗失败: {str(e)}"
|
return False, bill_type, f"清洗失败: {str(e)}"
|
||||||
@@ -324,7 +334,7 @@ async def detect_bill_type_api(file: UploadFile = File(...)):
|
|||||||
"""
|
"""
|
||||||
检测账单类型
|
检测账单类型
|
||||||
|
|
||||||
上传文件后自动检测是支付宝还是微信账单
|
上传文件后自动检测是支付宝、微信还是京东账单
|
||||||
"""
|
"""
|
||||||
suffix = Path(file.filename).suffix or ".csv"
|
suffix = Path(file.filename).suffix or ".csv"
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
@@ -336,10 +346,10 @@ async def detect_bill_type_api(file: UploadFile = File(...)):
|
|||||||
if bill_type is None:
|
if bill_type is None:
|
||||||
raise HTTPException(status_code=400, detail="无法识别账单类型")
|
raise HTTPException(status_code=400, detail="无法识别账单类型")
|
||||||
|
|
||||||
type_names = {"alipay": "支付宝", "wechat": "微信"}
|
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
|
||||||
return {
|
return {
|
||||||
"bill_type": bill_type,
|
"bill_type": bill_type,
|
||||||
"display_name": type_names[bill_type]
|
"display_name": type_names.get(bill_type, bill_type)
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(tmp_path):
|
if os.path.exists(tmp_path):
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ type CleanOptions struct {
|
|||||||
|
|
||||||
// CleanResult 清洗结果
|
// CleanResult 清洗结果
|
||||||
type CleanResult struct {
|
type CleanResult struct {
|
||||||
BillType string // 检测到的账单类型: alipay/wechat
|
BillType string // 检测到的账单类型: alipay/wechat/jd
|
||||||
Output string // 脚本输出信息
|
Output string // 脚本输出信息
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertResult 格式转换结果
|
// ConvertResult 格式转换结果
|
||||||
type ConvertResult struct {
|
type ConvertResult struct {
|
||||||
OutputPath string // 转换后的文件路径
|
OutputPath string // 转换后的文件路径
|
||||||
BillType string // 检测到的账单类型: alipay/wechat
|
BillType string // 检测到的账单类型: alipay/wechat/jd
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleaner 账单清洗器接口
|
// Cleaner 账单清洗器接口
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ func detectBillTypeFromOutput(output string) string {
|
|||||||
if strings.Contains(output, "微信") {
|
if strings.Contains(output, "微信") {
|
||||||
return "wechat"
|
return "wechat"
|
||||||
}
|
}
|
||||||
|
if strings.Contains(output, "京东") {
|
||||||
|
return "jd"
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type ListBillsRequest struct {
|
|||||||
StartDate string `form:"start_date"` // 开始日期 YYYY-MM-DD
|
StartDate string `form:"start_date"` // 开始日期 YYYY-MM-DD
|
||||||
EndDate string `form:"end_date"` // 结束日期 YYYY-MM-DD
|
EndDate string `form:"end_date"` // 结束日期 YYYY-MM-DD
|
||||||
Category string `form:"category"` // 分类筛选
|
Category string `form:"category"` // 分类筛选
|
||||||
Type string `form:"type"` // 账单类型 alipay/wechat
|
Type string `form:"type"` // 账单类型 alipay/wechat/jd
|
||||||
IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出
|
IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"billai-server/config"
|
"billai-server/config"
|
||||||
"billai-server/model"
|
"billai-server/model"
|
||||||
|
"billai-server/repository"
|
||||||
"billai-server/service"
|
"billai-server/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,6 +146,8 @@ func Upload(c *gin.Context) {
|
|||||||
billType = "alipay"
|
billType = "alipay"
|
||||||
} else if strings.Contains(fileName, "微信") || strings.Contains(fileName, "wechat") {
|
} else if strings.Contains(fileName, "微信") || strings.Contains(fileName, "wechat") {
|
||||||
billType = "wechat"
|
billType = "wechat"
|
||||||
|
} else if strings.Contains(fileName, "京东") || strings.Contains(fileName, "jd") {
|
||||||
|
billType = "jd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if billType == "" {
|
if billType == "" {
|
||||||
@@ -152,15 +155,15 @@ func Upload(c *gin.Context) {
|
|||||||
service.CleanupExtractedFiles(extractedFiles)
|
service.CleanupExtractedFiles(extractedFiles)
|
||||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||||
Result: false,
|
Result: false,
|
||||||
Message: "无法识别账单类型,请指定 type 参数 (alipay 或 wechat)",
|
Message: "无法识别账单类型,请指定 type 参数 (alipay/wechat/jd)",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if billType != "alipay" && billType != "wechat" {
|
if billType != "alipay" && billType != "wechat" && billType != "jd" {
|
||||||
service.CleanupExtractedFiles(extractedFiles)
|
service.CleanupExtractedFiles(extractedFiles)
|
||||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||||
Result: false,
|
Result: false,
|
||||||
Message: "账单类型无效,仅支持 alipay 或 wechat",
|
Message: "账单类型无效,仅支持 alipay/wechat/jd",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -252,22 +255,41 @@ func Upload(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
service.CleanupExtractedFiles(extractedFiles)
|
service.CleanupExtractedFiles(extractedFiles)
|
||||||
|
|
||||||
// 13. 返回成功响应
|
// 13. 如果是京东账单,软删除其他来源中包含"京东-订单编号"的记录
|
||||||
|
var jdRelatedDeleted int64
|
||||||
|
if billType == "jd" {
|
||||||
|
repo := repository.GetRepository()
|
||||||
|
if repo != nil {
|
||||||
|
deleted, err := repo.SoftDeleteJDRelatedBills()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err)
|
||||||
|
} else if deleted > 0 {
|
||||||
|
jdRelatedDeleted = deleted
|
||||||
|
fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. 返回成功响应
|
||||||
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
|
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
|
||||||
if dedupResult.DuplicateCount > 0 {
|
if dedupResult.DuplicateCount > 0 {
|
||||||
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
||||||
}
|
}
|
||||||
|
if jdRelatedDeleted > 0 {
|
||||||
|
message = fmt.Sprintf("%s,标记删除 %d 条重复的京东订单", message, jdRelatedDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.UploadResponse{
|
c.JSON(http.StatusOK, model.UploadResponse{
|
||||||
Result: true,
|
Result: true,
|
||||||
Message: message,
|
Message: message,
|
||||||
Data: &model.UploadData{
|
Data: &model.UploadData{
|
||||||
BillType: billType,
|
BillType: billType,
|
||||||
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
||||||
FileName: outputFileName,
|
FileName: outputFileName,
|
||||||
RawCount: rawCount,
|
RawCount: rawCount,
|
||||||
CleanedCount: cleanedCount,
|
CleanedCount: cleanedCount,
|
||||||
DuplicateCount: dedupResult.DuplicateCount,
|
DuplicateCount: dedupResult.DuplicateCount,
|
||||||
|
JDRelatedDeleted: jdRelatedDeleted,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func (t LocalTime) Time() time.Time {
|
|||||||
// RawBill 原始账单记录(存储上传的原始数据)
|
// RawBill 原始账单记录(存储上传的原始数据)
|
||||||
type RawBill struct {
|
type RawBill struct {
|
||||||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
||||||
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
|
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat/jd
|
||||||
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
||||||
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
|
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
|
||||||
RowIndex int `bson:"row_index" json:"row_index"` // 原始行号
|
RowIndex int `bson:"row_index" json:"row_index"` // 原始行号
|
||||||
@@ -81,7 +81,7 @@ type RawBill struct {
|
|||||||
// CleanedBill 清洗后账单记录(标准化后的数据)
|
// CleanedBill 清洗后账单记录(标准化后的数据)
|
||||||
type CleanedBill struct {
|
type CleanedBill struct {
|
||||||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
||||||
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
|
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat/jd
|
||||||
TransactionID string `bson:"transaction_id" json:"transaction_id"` // 交易订单号(用于去重)
|
TransactionID string `bson:"transaction_id" json:"transaction_id"` // 交易订单号(用于去重)
|
||||||
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
|
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
|
||||||
Time LocalTime `bson:"time" json:"time"` // 交易时间(本地时间格式)
|
Time LocalTime `bson:"time" json:"time"` // 交易时间(本地时间格式)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package model
|
|||||||
|
|
||||||
// UploadRequest 上传请求参数
|
// UploadRequest 上传请求参数
|
||||||
type UploadRequest struct {
|
type UploadRequest struct {
|
||||||
Type string `form:"type"` // 账单类型: alipay/wechat(可选,会自动检测)
|
Type string `form:"type"` // 账单类型: alipay/wechat/jd(可选,会自动检测)
|
||||||
Password string `form:"password"` // ZIP 文件密码(可选)
|
Password string `form:"password"` // ZIP 文件密码(可选)
|
||||||
Year string `form:"year"` // 年份筛选
|
Year string `form:"year"` // 年份筛选
|
||||||
Month string `form:"month"` // 月份筛选
|
Month string `form:"month"` // 月份筛选
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ package model
|
|||||||
|
|
||||||
// UploadData 上传响应数据
|
// UploadData 上传响应数据
|
||||||
type UploadData struct {
|
type UploadData struct {
|
||||||
BillType string `json:"bill_type,omitempty"` // alipay/wechat
|
BillType string `json:"bill_type,omitempty"` // alipay/wechat/jd
|
||||||
FileURL string `json:"file_url,omitempty"` // 下载链接
|
FileURL string `json:"file_url,omitempty"` // 下载链接
|
||||||
FileName string `json:"file_name,omitempty"` // 文件名
|
FileName string `json:"file_name,omitempty"` // 文件名
|
||||||
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
|
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
|
||||||
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
|
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
|
||||||
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
|
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
|
||||||
|
JDRelatedDeleted int64 `json:"jd_related_deleted,omitempty"` // 软删除的京东关联记录数(其他来源中描述包含京东订单号的记录)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResponse 上传响应
|
// UploadResponse 上传响应
|
||||||
|
|||||||
@@ -458,6 +458,41 @@ func (r *Repository) DeleteCleanedBillByID(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SoftDeleteJDRelatedBills 软删除描述中包含"京东-订单编号"的非京东账单
|
||||||
|
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
|
||||||
|
func (r *Repository) SoftDeleteJDRelatedBills() (int64, error) {
|
||||||
|
if r.cleanedCollection == nil {
|
||||||
|
return 0, fmt.Errorf("cleaned collection not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 筛选条件:
|
||||||
|
// 1. 账单类型不是 jd(只处理微信、支付宝等其他来源)
|
||||||
|
// 2. 描述中包含"京东-订单编号"
|
||||||
|
// 3. 尚未被删除
|
||||||
|
filter := bson.M{
|
||||||
|
"bill_type": bson.M{"$ne": "jd"},
|
||||||
|
"description": bson.M{"$regex": "京东-订单编号", "$options": ""},
|
||||||
|
"is_deleted": bson.M{"$ne": true},
|
||||||
|
}
|
||||||
|
|
||||||
|
update := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"is_deleted": true,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.cleanedCollection.UpdateMany(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("soft delete JD related bills failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ModifiedCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
||||||
func (r *Repository) GetClient() *mongo.Client {
|
func (r *Repository) GetClient() *mongo.Client {
|
||||||
return r.client
|
return r.client
|
||||||
|
|||||||
@@ -51,4 +51,9 @@ type BillRepository interface {
|
|||||||
|
|
||||||
// CountRawByField 按字段统计原始数据数量
|
// CountRawByField 按字段统计原始数据数量
|
||||||
CountRawByField(fieldName, value string) (int64, error)
|
CountRawByField(fieldName, value string) (int64, error)
|
||||||
|
|
||||||
|
// SoftDeleteJDRelatedBills 软删除描述中包含"京东-订单编号"的非京东账单
|
||||||
|
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
|
||||||
|
// 返回: 删除数量、错误
|
||||||
|
SoftDeleteJDRelatedBills() (int64, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ func ExtractZip(zipPath, destDir, password string) (*ExtractResult, error) {
|
|||||||
result.BillType = "alipay"
|
result.BillType = "alipay"
|
||||||
} else if strings.Contains(fileName, "微信") || strings.Contains(strings.ToLower(fileName), "wechat") {
|
} else if strings.Contains(fileName, "微信") || strings.Contains(strings.ToLower(fileName), "wechat") {
|
||||||
result.BillType = "wechat"
|
result.BillType = "wechat"
|
||||||
|
} else if strings.Contains(fileName, "京东") || strings.Contains(strings.ToLower(fileName), "jd") {
|
||||||
|
result.BillType = "jd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ func detectBillTypeAndIdField(header []string) (billType string, idFieldIdx int)
|
|||||||
if col == "交易类型" || col == "金额(元)" {
|
if col == "交易类型" || col == "金额(元)" {
|
||||||
billType = "wechat"
|
billType = "wechat"
|
||||||
}
|
}
|
||||||
|
// 京东特征
|
||||||
|
if col == "商户名称" || col == "交易说明" {
|
||||||
|
billType = "jd"
|
||||||
|
}
|
||||||
|
|
||||||
// 查找去重字段(优先使用交易订单号/交易号)
|
// 查找去重字段(优先使用交易订单号/交易号)
|
||||||
if col == "交易订单号" || col == "交易号" || col == "交易单号" {
|
if col == "交易订单号" || col == "交易号" || col == "交易单号" {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ func DetectBillTypeFromOutput(output string) string {
|
|||||||
if containsSubstring(output, "微信") {
|
if containsSubstring(output, "微信") {
|
||||||
return "wechat"
|
return "wechat"
|
||||||
}
|
}
|
||||||
|
if containsSubstring(output, "京东") {
|
||||||
|
return "jd"
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function checkHealth(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
export type BillType = 'alipay' | 'wechat';
|
export type BillType = 'alipay' | 'wechat' | 'jd';
|
||||||
|
|
||||||
export interface UploadData {
|
export interface UploadData {
|
||||||
bill_type: BillType;
|
bill_type: BillType;
|
||||||
|
|||||||
@@ -213,6 +213,8 @@
|
|||||||
selectedType = 'alipay';
|
selectedType = 'alipay';
|
||||||
} else if (fileName.includes('微信') || fileName.includes('wechat')) {
|
} else if (fileName.includes('微信') || fileName.includes('wechat')) {
|
||||||
selectedType = 'wechat';
|
selectedType = 'wechat';
|
||||||
|
} else if (fileName.includes('京东') || fileName.includes('jd')) {
|
||||||
|
selectedType = 'jd';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +265,7 @@
|
|||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight">账单管理</h1>
|
<h1 class="text-2xl font-bold tracking-tight">账单管理</h1>
|
||||||
<p class="text-muted-foreground">上传并分析您的支付宝、微信账单</p>
|
<p class="text-muted-foreground">上传并分析您的支付宝、微信、京东账单</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
@@ -297,7 +299,7 @@
|
|||||||
<Card.Header class="flex flex-row items-center justify-between space-y-0">
|
<Card.Header class="flex flex-row items-center justify-between space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<Card.Title>上传账单</Card.Title>
|
<Card.Title>上传账单</Card.Title>
|
||||||
<Card.Description>支持支付宝、微信账单 CSV、XLSX 或 ZIP 文件</Card.Description>
|
<Card.Description>支持支付宝、微信、京东账单 CSV、XLSX 或 ZIP 文件</Card.Description>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onclick={() => goto('/bills?tab=manual')}>
|
<Button variant="outline" size="sm" onclick={() => goto('/bills?tab=manual')}>
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
@@ -397,6 +399,13 @@
|
|||||||
>
|
>
|
||||||
微信
|
微信
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedType === 'jd' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => selectedType = 'jd'}
|
||||||
|
>
|
||||||
|
京东
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -438,7 +447,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-muted-foreground">账单类型</span>
|
<span class="text-sm text-muted-foreground">账单类型</span>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : '微信'}
|
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : uploadResult.data?.bill_type === 'wechat' ? '微信' : '京东'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -380,13 +380,14 @@
|
|||||||
<Label class="text-xs">来源</Label>
|
<Label class="text-xs">来源</Label>
|
||||||
<Select.Root type="single" value={filterBillType || undefined} onValueChange={handleBillTypeChange}>
|
<Select.Root type="single" value={filterBillType || undefined} onValueChange={handleBillTypeChange}>
|
||||||
<Select.Trigger class="h-9 w-full">
|
<Select.Trigger class="h-9 w-full">
|
||||||
<span class="text-sm">{filterBillType === 'alipay' ? '支付宝' : filterBillType === 'wechat' ? '微信' : filterBillType === 'manual' ? '手动' : '全部'}</span>
|
<span class="text-sm">{filterBillType === 'alipay' ? '支付宝' : filterBillType === 'wechat' ? '微信' : filterBillType === 'jd' ? '京东' : filterBillType === 'manual' ? '手动' : '全部'}</span>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Portal>
|
<Select.Portal>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
<Select.Item value="">全部</Select.Item>
|
<Select.Item value="">全部</Select.Item>
|
||||||
<Select.Item value="alipay">支付宝</Select.Item>
|
<Select.Item value="alipay">支付宝</Select.Item>
|
||||||
<Select.Item value="wechat">微信</Select.Item>
|
<Select.Item value="wechat">微信</Select.Item>
|
||||||
|
<Select.Item value="jd">京东</Select.Item>
|
||||||
<Select.Item value="manual">手动</Select.Item>
|
<Select.Item value="manual">手动</Select.Item>
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
@@ -438,8 +439,8 @@
|
|||||||
{formatDateTime(record.time)}
|
{formatDateTime(record.time)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="hidden xl:table-cell">
|
<Table.Cell class="hidden xl:table-cell">
|
||||||
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : 'secondary')}>
|
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : (record.bill_type === 'jd' ? 'destructive' : 'secondary'))}>
|
||||||
{record.bill_type === 'manual' ? '手动输入' : (record.bill_type === 'alipay' ? '支付宝' : '微信')}
|
{record.bill_type === 'manual' ? '手动输入' : (record.bill_type === 'alipay' ? '支付宝' : (record.bill_type === 'jd' ? '京东' : '微信'))}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
|||||||
Reference in New Issue
Block a user