Spaces:
Paused
Paused
Update process_report.py
Browse files- process_report.py +362 -200
process_report.py
CHANGED
|
@@ -1,243 +1,405 @@
|
|
| 1 |
#!/usr/bin/env python3
|
|
|
|
| 2 |
"""
|
| 3 |
process_report.py
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
|
|
|
| 10 |
import os
|
| 11 |
import sys
|
| 12 |
-
import base64
|
| 13 |
import glob
|
| 14 |
import json
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
import pandas as pd
|
| 17 |
import requests
|
| 18 |
|
| 19 |
-
#
|
| 20 |
-
INPUT_DIR = os.environ.get(
|
| 21 |
-
OUTPUT_DIR = os.environ.get(
|
| 22 |
-
RESEND_API_KEY = os.environ.get('RESEND_API_KEY')
|
| 23 |
-
FROM_EMAIL = os.environ.get('FROM_EMAIL')
|
| 24 |
-
TO_EMAIL = os.environ.get('TO_EMAIL')
|
| 25 |
-
TIMEZONE = os.environ.get('TZ', 'Asia/Shanghai')
|
| 26 |
-
|
| 27 |
os.makedirs(INPUT_DIR, exist_ok=True)
|
| 28 |
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
files = []
|
| 35 |
-
for
|
| 36 |
-
files.extend(glob.glob(
|
| 37 |
if not files:
|
| 38 |
return None
|
| 39 |
-
files
|
| 40 |
return files[0]
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
# 备用尝试:不指定 header
|
| 49 |
-
df = pd.read_excel(path, header=0)
|
| 50 |
-
# 丢弃全空列
|
| 51 |
-
df = df.dropna(axis=1, how='all')
|
| 52 |
-
return df
|
| 53 |
|
| 54 |
|
| 55 |
-
def
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
for c in num_cols:
|
| 74 |
-
if c in proc.columns:
|
| 75 |
-
proc[c] = pd.to_numeric(proc[c], errors='coerce').fillna(0)
|
| 76 |
-
|
| 77 |
-
# 聚合键
|
| 78 |
-
group_keys = [
|
| 79 |
-
k for k in [
|
| 80 |
-
'请购日期','请购单号','物料编码','物料名称','纱支密度','门幅(CM)',
|
| 81 |
-
'颜色','主单位','主数量','需求日期','供应商'
|
| 82 |
-
] if k in proc.columns
|
| 83 |
-
]
|
| 84 |
|
| 85 |
-
agg_map = {}
|
| 86 |
-
if '到货日期' in proc.columns:
|
| 87 |
-
agg_map['到货日期'] = 'max'
|
| 88 |
-
if '到货主数量' in proc.columns:
|
| 89 |
-
agg_map['到货主数量'] = 'sum'
|
| 90 |
-
if '入库日期' in proc.columns:
|
| 91 |
-
agg_map['入库日期'] = 'max'
|
| 92 |
-
if '入库主数量' in proc.columns:
|
| 93 |
-
agg_map['入库主数量'] = 'sum'
|
| 94 |
-
if '计划到货日期' in proc.columns:
|
| 95 |
-
agg_map['计划到货日期'] = 'max'
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def calc_progress(row):
|
| 106 |
-
main_qty = row.get('主数量', 0) or 0
|
| 107 |
-
arr_qty = row.get('到货主数量', 0) or 0
|
| 108 |
-
demand_date = row.get('需求日期', pd.NaT)
|
| 109 |
-
arrival_date = row.get('到货日期', pd.NaT)
|
| 110 |
-
plan_arrival = row.get('计划到货日期', pd.NaT)
|
| 111 |
-
|
| 112 |
-
# 完全到货
|
| 113 |
-
if main_qty > 0 and arr_qty >= main_qty:
|
| 114 |
-
return '完全到货'
|
| 115 |
-
|
| 116 |
-
shortage = max(0, main_qty - arr_qty)
|
| 117 |
-
|
| 118 |
-
# 未到或部分到
|
| 119 |
-
if pd.isna(arrival_date) or arr_qty < main_qty:
|
| 120 |
-
# 逾期判断
|
| 121 |
-
if pd.notna(demand_date) and today > demand_date:
|
| 122 |
-
overdue_days = (today - demand_date).days
|
| 123 |
-
if arr_qty > 0:
|
| 124 |
-
return f'部分到货 缺货{shortage:g}(已逾期{overdue_days}天)'
|
| 125 |
-
else:
|
| 126 |
-
return f'未到货(已逾期{overdue_days}天)'
|
| 127 |
-
else:
|
| 128 |
-
# 未来7天到货
|
| 129 |
-
if pd.notna(plan_arrival) and today <= plan_arrival <= today + pd.Timedelta(days=7):
|
| 130 |
-
return f'未来7天到货(计划{plan_arrival.date()})'
|
| 131 |
-
return f'部分到货 缺货{shortage:g}' if arr_qty > 0 else '未到货(未到期)'
|
| 132 |
-
return '完全到货'
|
| 133 |
-
|
| 134 |
-
agg['目前进度'] = agg.apply(calc_progress, axis=1)
|
| 135 |
-
|
| 136 |
-
# 按邮件模板列排序(若缺列,保持存在列)
|
| 137 |
-
email_cols = [
|
| 138 |
-
'请购日期','请购单号','物料编码','物料名称','纱支密度','门幅(CM)','颜色','主单位',
|
| 139 |
-
'主数量','需求日期','供应商','到货日期','到货主数量','入库日期','入库主数量','目前进度'
|
| 140 |
-
]
|
| 141 |
-
final_cols = [c for c in email_cols if c in agg.columns]
|
| 142 |
-
out = agg[final_cols].copy()
|
| 143 |
-
return out
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def df_to_excel_bytes(df):
|
| 147 |
-
from io import BytesIO
|
| 148 |
-
b = BytesIO()
|
| 149 |
-
df.to_excel(b, index=False)
|
| 150 |
-
b.seek(0)
|
| 151 |
-
return b.read()
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
def send_email_via_resend(subject, html_body, attachment_bytes=None, attachment_name='report.xlsx'):
|
| 155 |
-
if not RESEND_API_KEY or not FROM_EMAIL or not TO_EMAIL:
|
| 156 |
-
print('缺少 Resend 配置:RESEND_API_KEY / FROM_EMAIL / TO_EMAIL', file=sys.stderr)
|
| 157 |
-
return False
|
| 158 |
-
|
| 159 |
-
url = 'https://api.resend.com/emails'
|
| 160 |
headers = {
|
| 161 |
-
|
| 162 |
-
|
| 163 |
}
|
| 164 |
-
|
| 165 |
payload = {
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
}
|
| 171 |
-
|
| 172 |
if attachment_bytes is not None:
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
'content': b64
|
| 178 |
-
}
|
| 179 |
-
]
|
| 180 |
|
| 181 |
resp = requests.post(url, headers=headers, data=json.dumps(payload))
|
| 182 |
-
|
| 183 |
-
resp.
|
| 184 |
-
|
| 185 |
-
print('发送邮件失败,状态码:', resp.status_code, resp.text, file=sys.stderr)
|
| 186 |
-
return False
|
| 187 |
-
print('邮件发送成功,响应:', resp.text)
|
| 188 |
-
return True
|
| 189 |
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
# 简单的 HTML: 标题 + 表格
|
| 194 |
-
table_html = df.to_html(index=False, escape=False)
|
| 195 |
-
html = f"""
|
| 196 |
-
<html>
|
| 197 |
-
<head>
|
| 198 |
-
<meta charset='utf-8' />
|
| 199 |
-
<style>
|
| 200 |
-
table {{ border-collapse: collapse; }}
|
| 201 |
-
table, th, td {{ border: 1px solid #ccc; padding: 6px; }}
|
| 202 |
-
th {{ background:#f3f3f3; }}
|
| 203 |
-
</style>
|
| 204 |
-
</head>
|
| 205 |
-
<body>
|
| 206 |
-
<h3>{title}</h3>
|
| 207 |
-
{table_html}
|
| 208 |
-
<p>备注:此邮件由自动化系统生成。</p>
|
| 209 |
-
</body>
|
| 210 |
-
</html>
|
| 211 |
"""
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
return
|
| 220 |
-
print('使用输入文件:', latest)
|
| 221 |
|
| 222 |
-
raw = read_system_export(
|
| 223 |
-
|
| 224 |
|
| 225 |
-
# 输出文件名
|
| 226 |
-
basename = os.path.splitext(os.path.basename(latest))[0]
|
| 227 |
out_name = f"邮件发送的格式_{datetime.now().strftime('%Y%m%d')}.xlsx"
|
| 228 |
out_path = os.path.join(OUTPUT_DIR, out_name)
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
-
# 生成邮件正文并发送
|
| 233 |
-
html = build_html_body(out, title=f'采购执行表({datetime.now().date()})')
|
| 234 |
-
attachment = df_to_excel_bytes(out)
|
| 235 |
-
subject = f'采购执行表自动推送 {datetime.now().date()}'
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
|
| 242 |
-
if __name__ ==
|
| 243 |
-
|
|
|
|
|
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
"""
|
| 4 |
process_report.py
|
| 5 |
+
读取最新或指定的“系统导出格式”Excel,按业务口径聚合为“邮件发送的格式”,
|
| 6 |
+
在“目前进度”列写入:
|
| 7 |
+
- 完全到货
|
| 8 |
+
- 部分到货 缺货X米(X=主数量-到货主数量汇总)
|
| 9 |
+
- 未到货(含逾期天数)
|
| 10 |
+
- 未来7天到货(依据 计划到货日期)
|
| 11 |
+
并通过 Resend 发送邮件(HTML表格 + Excel附件)。
|
| 12 |
+
|
| 13 |
+
环境变量(Hugging Face → Settings → Variables and secrets → Repository secrets):
|
| 14 |
+
RESEND_API_KEY : Resend 的 API Key(必须)
|
| 15 |
+
FROM_EMAIL : 发件人,例如 "采购机器人 <bot@your-domain.com>"(必须)
|
| 16 |
+
TO_EMAIL : 收件人,例如 "you@your-company.com"(必须)
|
| 17 |
+
INPUT_DIR : 输入目录(默认 /tmp/uploads)
|
| 18 |
+
OUTPUT_DIR : 输出目录(默认 /tmp/outputs)
|
| 19 |
+
TZ : 时区(默认 Asia/Shanghai)
|
| 20 |
+
|
| 21 |
+
使用方式:
|
| 22 |
+
1) 上传文件到 INPUT_DIR 后,调用 main(trigger_file=该文件路径)
|
| 23 |
+
2) 或命令行:python process_report.py [可选:具体文件路径]
|
| 24 |
"""
|
| 25 |
+
|
| 26 |
import os
|
| 27 |
import sys
|
|
|
|
| 28 |
import glob
|
| 29 |
import json
|
| 30 |
+
import base64
|
| 31 |
+
from io import BytesIO
|
| 32 |
+
from typing import Optional, Tuple, List
|
| 33 |
+
|
| 34 |
+
from datetime import datetime, date, timedelta
|
| 35 |
+
|
| 36 |
import pandas as pd
|
| 37 |
import requests
|
| 38 |
|
| 39 |
+
# ====== 目录 & 环境 ======
|
| 40 |
+
INPUT_DIR = os.environ.get("INPUT_DIR", "/tmp/uploads")
|
| 41 |
+
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/tmp/outputs")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
os.makedirs(INPUT_DIR, exist_ok=True)
|
| 43 |
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 44 |
|
| 45 |
+
RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
|
| 46 |
+
FROM_EMAIL = os.environ.get("FROM_EMAIL")
|
| 47 |
+
TO_EMAIL = os.environ.get("TO_EMAIL")
|
| 48 |
+
TIMEZONE = os.environ.get("TZ", "Asia/Shanghai")
|
| 49 |
+
|
| 50 |
+
# ====== 业务相关字段(尽量兼容括号全角/半角差异)======
|
| 51 |
+
# 一些系统表可能列名略有差异,这里做一个“同义列名”匹配表
|
| 52 |
+
ALIASES = {
|
| 53 |
+
"请购日期": ["请购日期", "请购日", "申请日期"],
|
| 54 |
+
"请购单号": ["请购单号", "请购单编号", "申请单号"],
|
| 55 |
+
"物料编码": ["物料编码", "物料号", "物料代码"],
|
| 56 |
+
"物料名称": ["物料名称", "品名", "名称"],
|
| 57 |
+
"纱支密度": ["纱支密度", "纱支/密度", "纱支 密度"],
|
| 58 |
+
"门幅(CM)": ["门幅(CM)", "门幅(CM)", "门幅cm", "门幅"],
|
| 59 |
+
"颜色": ["颜色", "色号/颜色", "色号"],
|
| 60 |
+
"主单位": ["主单位", "单位"],
|
| 61 |
+
"主数量": ["主数量", "数量", "请购数量"],
|
| 62 |
+
"需求日期": ["需求日期", "需求日", "交期", "要求到货日期"],
|
| 63 |
+
"供应商": ["供应商", "供货商", "供应商名称"],
|
| 64 |
+
"到货日期": ["到货日期", "实到日期", "收货日期"],
|
| 65 |
+
"到货主数量": ["到货主数量", "到货数量", "实到数量"],
|
| 66 |
+
"入库日期": ["入库日期", "入库日"],
|
| 67 |
+
"入库主数量": ["入库主数量", "入库数量"],
|
| 68 |
+
"计划到货日期": ["计划到货日期", "预计到货日期", "承诺到货日期", "计划到货日"],
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# “邮件发送的格式”列顺序,如果检测到模板文件,会按模板优先排序
|
| 72 |
+
EMAIL_COLS_DEFAULT = [
|
| 73 |
+
"请购日期","请购单号","物料编码","物料名称","纱支密度","门幅(CM)","颜色","主单位",
|
| 74 |
+
"主数量","需求日期","供应商","到货日期","到货主数量","入库日期","入库主数量","目前进度"
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
TEMPLATE_CANDIDATES = [
|
| 78 |
+
# 若你把模板Excel放进仓库根目录或 templates 目录,可被自动识别
|
| 79 |
+
"/workspace/邮件发送的格式.xlsx",
|
| 80 |
+
"/workspace/templates/邮件发送的格式.xlsx",
|
| 81 |
+
"/app/邮件发送的格式.xlsx",
|
| 82 |
+
"/app/templates/邮件发送的格式.xlsx",
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ====== 工具函数 ======
|
| 87 |
+
def _today() -> date:
|
| 88 |
+
# 用本地系统日期即可(Space容器时区一般是UTC;你可在 Space 里设 TZ 环境变量 + tzdata 以保证正确)
|
| 89 |
+
return datetime.now().date()
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
|
| 93 |
+
"""
|
| 94 |
+
将 DataFrame 的列名映射到标准中文名(按 ALIASES)。
|
| 95 |
+
例如:'门幅(CM)' -> '门幅(CM)'
|
| 96 |
+
"""
|
| 97 |
+
mapped = {}
|
| 98 |
+
for std_name, variants in ALIASES.items():
|
| 99 |
+
for v in df.columns:
|
| 100 |
+
v_clean = str(v).strip()
|
| 101 |
+
if v_clean in variants:
|
| 102 |
+
mapped[v] = std_name
|
| 103 |
+
break
|
| 104 |
+
df = df.rename(columns=mapped)
|
| 105 |
+
return df
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _find_header_row(path: str, must_have: List[str] = None, try_rows: int = 10) -> int:
|
| 109 |
+
"""
|
| 110 |
+
尝试在前 try_rows 行中找到包含关键列(如“物料编码”、“主数量”)的表头行。
|
| 111 |
+
找不到则返回 0。
|
| 112 |
+
"""
|
| 113 |
+
must_have = must_have or ["物料编码", "主数量"]
|
| 114 |
+
for r in range(try_rows):
|
| 115 |
+
try:
|
| 116 |
+
df_try = pd.read_excel(path, header=r, nrows=1)
|
| 117 |
+
except Exception:
|
| 118 |
+
continue
|
| 119 |
+
cols = [str(c).strip() for c in df_try.columns]
|
| 120 |
+
if all(any(m in c for c in cols) or m in cols for m in must_have):
|
| 121 |
+
return r
|
| 122 |
+
return 0
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def read_system_export(path: str) -> pd.DataFrame:
|
| 126 |
+
"""
|
| 127 |
+
读取“系统导出格式”Excel,并做列名标准化、空列丢弃、日期/数字类型转换。
|
| 128 |
+
"""
|
| 129 |
+
header_row = _find_header_row(path)
|
| 130 |
+
try:
|
| 131 |
+
df = pd.read_excel(path, header=header_row)
|
| 132 |
+
except Exception:
|
| 133 |
+
df = pd.read_excel(path, header=0)
|
| 134 |
+
|
| 135 |
+
# 丢掉全空列
|
| 136 |
+
df = df.dropna(axis=1, how="all")
|
| 137 |
+
# 标准化列名
|
| 138 |
+
df = _normalize_columns(df)
|
| 139 |
+
|
| 140 |
+
# 转日期
|
| 141 |
+
for c in ["请购日期","需求日期","到货日期","入库日期","计划到货日期"]:
|
| 142 |
+
if c in df.columns:
|
| 143 |
+
df[c] = pd.to_datetime(df[c], errors="coerce")
|
| 144 |
+
|
| 145 |
+
# 转数字
|
| 146 |
+
for c in ["主数量","到货主数量","入库主数量"]:
|
| 147 |
+
if c in df.columns:
|
| 148 |
+
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0)
|
| 149 |
+
|
| 150 |
+
# 去掉全空行
|
| 151 |
+
df = df.dropna(how="all")
|
| 152 |
+
return df
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _first_nonnull(series: pd.Series):
|
| 156 |
+
for v in series:
|
| 157 |
+
if pd.notna(v):
|
| 158 |
+
return v
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def aggregate_for_email(df: pd.DataFrame) -> pd.DataFrame:
|
| 163 |
+
"""
|
| 164 |
+
业务汇总规则:
|
| 165 |
+
- “主数量”不做分类汇总(保留原单的主数量),
|
| 166 |
+
- “到货主数量”、“入库主数量”需要汇总求和,
|
| 167 |
+
- “到货日期/入库日期/计划到货日期”取最大(最近),
|
| 168 |
+
- 分组键:不包含数量列,常用维度如下(尽量稳定,避免将数量/日期放入分组键):
|
| 169 |
+
请购单号、物料编码、物料名称、纱支密度、门幅(CM)、颜色、主单位、供应商
|
| 170 |
+
- “请购日期/需求日期”保留“首个非空”
|
| 171 |
+
"""
|
| 172 |
+
group_keys = [k for k in [
|
| 173 |
+
"请购单号","物料编码","物料名称","纱支密度","门幅(CM)","颜色","主单位","供应商"
|
| 174 |
+
] if k in df.columns]
|
| 175 |
+
|
| 176 |
+
if not group_keys:
|
| 177 |
+
raise RuntimeError("找不到用于分组的关键字段(如 请购单号/物料编码 等),请检查导入的表头。")
|
| 178 |
+
|
| 179 |
+
agg_map = {}
|
| 180 |
+
|
| 181 |
+
# 不汇总主数量:取首个非空(假设同一分组合并后主数量一致)
|
| 182 |
+
if "主数量" in df.columns:
|
| 183 |
+
agg_map["主数量"] = _first_nonnull
|
| 184 |
+
|
| 185 |
+
# 日期字段
|
| 186 |
+
if "请购日期" in df.columns:
|
| 187 |
+
agg_map["请购日期"] = _first_nonnull
|
| 188 |
+
if "需求日期" in df.columns:
|
| 189 |
+
agg_map["需求日期"] = _first_nonnull
|
| 190 |
+
|
| 191 |
+
if "到货日期" in df.columns:
|
| 192 |
+
agg_map["到货日期"] = "max"
|
| 193 |
+
if "入库日期" in df.columns:
|
| 194 |
+
agg_map["入库日期"] = "max"
|
| 195 |
+
if "计划到货日期" in df.columns:
|
| 196 |
+
agg_map["计划到货日期"] = "max"
|
| 197 |
+
|
| 198 |
+
# 数量汇总
|
| 199 |
+
if "到货主数量" in df.columns:
|
| 200 |
+
agg_map["到货主数量"] = "sum"
|
| 201 |
+
if "入库主数量" in df.columns:
|
| 202 |
+
agg_map["入库主数量"] = "sum"
|
| 203 |
+
|
| 204 |
+
grouped = df.groupby(group_keys, dropna=False).agg(agg_map).reset_index()
|
| 205 |
+
|
| 206 |
+
# 计算 “目前进度”
|
| 207 |
+
grouped["目前进度"] = grouped.apply(_calc_progress_row, axis=1)
|
| 208 |
|
| 209 |
+
# 最终列顺序:优先按模板,其次按默认顺序
|
| 210 |
+
final = _order_like_template(grouped)
|
| 211 |
+
return final
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _calc_progress_row(row: pd.Series) -> str:
|
| 215 |
+
"""
|
| 216 |
+
目前进度的业务口径:
|
| 217 |
+
1) 到货主数量 ≥ 主数量 => “完全到货”
|
| 218 |
+
2) 否则:
|
| 219 |
+
- 若到货主数量 > 0 => “部分到货 缺货X米”
|
| 220 |
+
- 若到货主数量 == 0 且到货日期为空 => “未到货”
|
| 221 |
+
- 逾期:需求日期存在且 today > 需求日期,追加 “已逾期Y天”
|
| 222 |
+
- 未来7天到货:计划到货日期在 [today, today+7] 区间,追加 “未来7天到货(计划YYYY-MM-DD)”
|
| 223 |
+
"""
|
| 224 |
+
today = pd.Timestamp(_today())
|
| 225 |
+
main_qty = float(row.get("主数量", 0) or 0)
|
| 226 |
+
arr_qty = float(row.get("到货主数量", 0) or 0)
|
| 227 |
+
|
| 228 |
+
demand_date = row.get("需求日期", pd.NaT)
|
| 229 |
+
arrival_date = row.get("到货日期", pd.NaT)
|
| 230 |
+
plan_arrival = row.get("计划到货日期", pd.NaT)
|
| 231 |
+
|
| 232 |
+
# 完全到货
|
| 233 |
+
if main_qty > 0 and arr_qty >= main_qty:
|
| 234 |
+
return "完全到货"
|
| 235 |
+
|
| 236 |
+
parts: List[str] = []
|
| 237 |
+
|
| 238 |
+
# 部分/未到
|
| 239 |
+
shortage = max(0.0, main_qty - arr_qty)
|
| 240 |
+
if arr_qty > 0:
|
| 241 |
+
parts.append(f"部分到货 缺货{shortage:g}米")
|
| 242 |
+
else:
|
| 243 |
+
# 到货日期为空或数量为0都视为未到货
|
| 244 |
+
if pd.isna(arrival_date) or arr_qty == 0:
|
| 245 |
+
parts.append("未到货")
|
| 246 |
+
|
| 247 |
+
# 逾期天数
|
| 248 |
+
if (pd.isna(arrival_date) or arr_qty < main_qty) and pd.notna(demand_date):
|
| 249 |
+
overdue_days = (today - pd.Timestamp(demand_date.date())).days
|
| 250 |
+
if overdue_days > 0:
|
| 251 |
+
parts.append(f"已逾期{overdue_days}天")
|
| 252 |
+
|
| 253 |
+
# 未来7天到货(计划)
|
| 254 |
+
if pd.notna(plan_arrival):
|
| 255 |
+
days_ahead = (pd.Timestamp(plan_arrival.date()) - today).days
|
| 256 |
+
if 0 <= days_ahead <= 7:
|
| 257 |
+
parts.append(f"未来7天到货(计划{str(plan_arrival.date())})")
|
| 258 |
+
|
| 259 |
+
# 如果什么都没有匹配,给一个保底描述
|
| 260 |
+
if not parts:
|
| 261 |
+
# 例如:需求未到期且无计划到货
|
| 262 |
+
if pd.notna(demand_date) and today <= pd.Timestamp(demand_date.date()):
|
| 263 |
+
return "未到货(未到期)"
|
| 264 |
+
return "处理中"
|
| 265 |
+
|
| 266 |
+
return ";".join(parts)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _order_like_template(df: pd.DataFrame) -> pd.DataFrame:
|
| 270 |
+
"""
|
| 271 |
+
若能找到“邮件发送的格式.xlsx”,按其表头顺序输出;否则用 EMAIL_COLS_DEFAULT。
|
| 272 |
+
模板里缺的列会自动从 df 里补;df 有但模板没有的列会追加在后面。
|
| 273 |
+
"""
|
| 274 |
+
template_cols = None
|
| 275 |
+
for p in TEMPLATE_CANDIDATES:
|
| 276 |
+
if os.path.exists(p):
|
| 277 |
+
try:
|
| 278 |
+
tdf = pd.read_excel(p, nrows=0)
|
| 279 |
+
template_cols = list(map(str, tdf.columns))
|
| 280 |
+
break
|
| 281 |
+
except Exception:
|
| 282 |
+
continue
|
| 283 |
+
|
| 284 |
+
if template_cols is None:
|
| 285 |
+
template_cols = EMAIL_COLS_DEFAULT
|
| 286 |
+
|
| 287 |
+
# 先取交集按顺序
|
| 288 |
+
front = [c for c in template_cols if c in df.columns]
|
| 289 |
+
# 再把 df 里其余列追加在后
|
| 290 |
+
tail = [c for c in df.columns if c not in front]
|
| 291 |
+
cols = front + tail
|
| 292 |
+
return df[cols].copy()
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _find_latest_input(input_dir: str) -> Optional[str]:
|
| 296 |
files = []
|
| 297 |
+
for pat in ("*.xlsx", "*.xls"):
|
| 298 |
+
files.extend(glob.glob(os.path.join(input_dir, pat)))
|
| 299 |
if not files:
|
| 300 |
return None
|
| 301 |
+
files.sort(key=os.path.getmtime, reverse=True)
|
| 302 |
return files[0]
|
| 303 |
|
| 304 |
|
| 305 |
+
def _df_to_excel_bytes(df: pd.DataFrame) -> bytes:
|
| 306 |
+
bio = BytesIO()
|
| 307 |
+
df.to_excel(bio, index=False)
|
| 308 |
+
bio.seek(0)
|
| 309 |
+
return bio.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
|
| 312 |
+
def _build_html_body(df: pd.DataFrame, title: str) -> str:
|
| 313 |
+
table_html = df.to_html(index=False, escape=False)
|
| 314 |
+
html = f"""<html>
|
| 315 |
+
<head>
|
| 316 |
+
<meta charset="utf-8" />
|
| 317 |
+
<style>
|
| 318 |
+
table {{ border-collapse: collapse; font-size: 13px; }}
|
| 319 |
+
table, th, td {{ border: 1px solid #ccc; padding: 6px; }}
|
| 320 |
+
th {{ background:#f6f6f6; }}
|
| 321 |
+
</style>
|
| 322 |
+
</head>
|
| 323 |
+
<body>
|
| 324 |
+
<h3>{title}</h3>
|
| 325 |
+
{table_html}
|
| 326 |
+
<p style="color:#666;">备注:此邮件由自动化系统生成。</p>
|
| 327 |
+
</body>
|
| 328 |
+
</html>"""
|
| 329 |
+
return html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
+
def _send_email_via_resend(subject: str, html_body: str,
|
| 333 |
+
attachment_bytes: Optional[bytes],
|
| 334 |
+
attachment_name: str) -> Tuple[bool, str]:
|
| 335 |
+
if not (RESEND_API_KEY and FROM_EMAIL and TO_EMAIL):
|
| 336 |
+
return False, "缺少 Resend 配置(RESEND_API_KEY / FROM_EMAIL / TO_EMAIL)"
|
| 337 |
+
|
| 338 |
+
url = "https://api.resend.com/emails"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
headers = {
|
| 340 |
+
"Authorization": f"Bearer {RESEND_API_KEY}",
|
| 341 |
+
"Content-Type": "application/json",
|
| 342 |
}
|
|
|
|
| 343 |
payload = {
|
| 344 |
+
"from": FROM_EMAIL,
|
| 345 |
+
"to": [TO_EMAIL],
|
| 346 |
+
"subject": subject,
|
| 347 |
+
"html": html_body,
|
| 348 |
}
|
|
|
|
| 349 |
if attachment_bytes is not None:
|
| 350 |
+
payload["attachments"] = [{
|
| 351 |
+
"filename": attachment_name,
|
| 352 |
+
"content": base64.b64encode(attachment_bytes).decode("utf-8"),
|
| 353 |
+
}]
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
resp = requests.post(url, headers=headers, data=json.dumps(payload))
|
| 356 |
+
if resp.ok:
|
| 357 |
+
return True, resp.text
|
| 358 |
+
return False, f"HTTP {resp.status_code}: {resp.text}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
|
| 360 |
|
| 361 |
+
# ====== 主流程 ======
|
| 362 |
+
def run_once(file_path: Optional[str] = None) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
"""
|
| 364 |
+
单次处理:读入Excel -> 汇总 -> 生成输出 -> 发邮件。
|
| 365 |
+
返回一个 dict 给上层(便于 app.py 返回给前端)。
|
| 366 |
+
"""
|
| 367 |
+
if file_path is None:
|
| 368 |
+
file_path = _find_latest_input(INPUT_DIR)
|
| 369 |
+
if not file_path:
|
| 370 |
+
return {"ok": False, "msg": f"未在 {INPUT_DIR} 找到Excel输入文件"}
|
|
|
|
|
|
|
| 371 |
|
| 372 |
+
raw = read_system_export(file_path)
|
| 373 |
+
final = aggregate_for_email(raw)
|
| 374 |
|
|
|
|
|
|
|
| 375 |
out_name = f"邮件发送的格式_{datetime.now().strftime('%Y%m%d')}.xlsx"
|
| 376 |
out_path = os.path.join(OUTPUT_DIR, out_name)
|
| 377 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 378 |
+
final.to_excel(out_path, index=False)
|
| 379 |
+
|
| 380 |
+
subject = f"采购执行表自动推送 {datetime.now().date()}"
|
| 381 |
+
html = _build_html_body(final, title=f"采购执行表({datetime.now().date()})")
|
| 382 |
+
attach = _df_to_excel_bytes(final)
|
| 383 |
+
ok, info = _send_email_via_resend(subject, html, attachment_bytes=attach, attachment_name=out_name)
|
| 384 |
+
|
| 385 |
+
return {
|
| 386 |
+
"ok": ok,
|
| 387 |
+
"msg": "邮件发送成功" if ok else f"邮件发送失败:{info}",
|
| 388 |
+
"input": file_path,
|
| 389 |
+
"output": out_path,
|
| 390 |
+
"rows": len(final),
|
| 391 |
+
}
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
|
| 394 |
+
def main(trigger_file: Optional[str] = None):
|
| 395 |
+
result = run_once(trigger_file)
|
| 396 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 397 |
+
# 返回码:成功0,失败1(便于将来做cron/健康检查)
|
| 398 |
+
if not result.get("ok"):
|
| 399 |
+
sys.exit(1)
|
| 400 |
|
| 401 |
|
| 402 |
+
if __name__ == "__main__":
|
| 403 |
+
# 允许命令行传入具体文件路径
|
| 404 |
+
arg_file = sys.argv[1] if len(sys.argv) > 1 else None
|
| 405 |
+
main(arg_file)
|