Spaces:
Paused
Paused
Update process_report.py
Browse files- process_report.py +136 -215
process_report.py
CHANGED
|
@@ -1,12 +1,10 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
"""
|
| 4 |
-
process_report.py
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
- 如果无法找到典型的分组键,会智能回退(选择前两个非数值列)并继续处理
|
| 9 |
-
- 返回结果里包含警告信息,便于前端展示排查
|
| 10 |
"""
|
| 11 |
|
| 12 |
import os
|
|
@@ -31,34 +29,37 @@ os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
| 31 |
|
| 32 |
RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
|
| 33 |
FROM_EMAIL = os.environ.get("FROM_EMAIL")
|
| 34 |
-
FROM_NAME = os.environ.get("FROM_NAME") # 可选,显示名
|
| 35 |
TO_EMAIL = os.environ.get("TO_EMAIL")
|
| 36 |
TIMEZONE = os.environ.get("TZ", "Asia/Shanghai")
|
| 37 |
|
| 38 |
-
#
|
|
|
|
|
|
|
|
|
|
| 39 |
SMTP_HOST = os.environ.get("SMTP_HOST")
|
| 40 |
SMTP_PORT = int(os.environ.get("SMTP_PORT", 587) or 587)
|
| 41 |
SMTP_USER = os.environ.get("SMTP_USER")
|
| 42 |
SMTP_PASS = os.environ.get("SMTP_PASS")
|
|
|
|
| 43 |
|
| 44 |
-
# ====== 业务相关字段
|
| 45 |
ALIASES = {
|
| 46 |
-
"请购日期": ["请购日期", "请购日", "申请日期"
|
| 47 |
-
"请购单号": ["请购单号", "请购单编号", "申请单号"
|
| 48 |
-
"物料编码": ["物料编码", "物料号", "物料代码"
|
| 49 |
-
"物料名称": ["物料名称", "品名", "名称"
|
| 50 |
-
"纱支密度": ["纱支密度", "纱支", "
|
| 51 |
-
"门幅(CM)": ["门幅(CM)", "门幅(CM)", "门幅cm", "门幅"
|
| 52 |
-
"颜色": ["颜色", "色号/颜色", "色号"
|
| 53 |
-
"主单位": ["主单位", "单位"
|
| 54 |
-
"主数量": ["主数量", "数量", "请购数量"
|
| 55 |
-
"需求日期": ["需求日期", "需求日", "交期", "要求到货日期"
|
| 56 |
-
"供应商": ["供应商", "供货商", "供应商名称"
|
| 57 |
-
"到货日期": ["到货日期", "实到日期", "收货日期"
|
| 58 |
-
"到货主数量": ["到货主数量", "到货数量", "实到数量"
|
| 59 |
-
"入库日期": ["入库日期", "入库日"
|
| 60 |
-
"入库主数量": ["入库主数量", "入库数量"
|
| 61 |
-
"计划到货日期": ["计划到货日期", "预计到货日期", "承诺到货日期", "
|
| 62 |
}
|
| 63 |
|
| 64 |
EMAIL_COLS_DEFAULT = [
|
|
@@ -66,64 +67,28 @@ EMAIL_COLS_DEFAULT = [
|
|
| 66 |
"主数量","需求日期","供应商","到货日期","到货主数量","入库日期","入库主数量","目前进度"
|
| 67 |
]
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
# ====== 工具函数 ======
|
| 70 |
def _today() -> date:
|
| 71 |
return datetime.now().date()
|
| 72 |
|
| 73 |
-
def _clean_col(c: str) -> str:
|
| 74 |
-
if c is None:
|
| 75 |
-
return ""
|
| 76 |
-
return str(c).strip().replace('\ufeff', '').replace('\u200b', '')
|
| 77 |
-
|
| 78 |
def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
|
| 79 |
-
|
| 80 |
-
使用 ALIASES 做尽可能多的列名匹配;若未匹配上,再做基于子串的小/英文匹配;
|
| 81 |
-
返回重命名后的 df 与已匹配的 mapping 列表(便于调试/返回警告)。
|
| 82 |
-
"""
|
| 83 |
-
mapping = {}
|
| 84 |
-
matched = {}
|
| 85 |
-
cols = [c for c in df.columns]
|
| 86 |
-
cleaned = {c: _clean_col(c) for c in cols}
|
| 87 |
-
|
| 88 |
-
# 先基于完全匹配(包括 ALIASES 列表)
|
| 89 |
for std_name, variants in ALIASES.items():
|
| 90 |
-
for
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
if c_clean.lower() == str(v).lower():
|
| 95 |
-
mapping[orig_col] = std_name
|
| 96 |
-
matched[orig_col] = std_name
|
| 97 |
-
break
|
| 98 |
-
if orig_col in mapping:
|
| 99 |
break
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
keyword_map = {
|
| 103 |
-
"请购单号": ["po", "order", "请购", "采购单"],
|
| 104 |
-
"物料编码": ["sku", "物料", "item code", "itemno", "item no", "物料号", "料号"],
|
| 105 |
-
"物料名称": ["品名", "物料名称", "description", "item name", "名称"],
|
| 106 |
-
"主数量": ["数量", "qty", "数量(米)", "amount"],
|
| 107 |
-
"供应商": ["供应商", "vendor", "supplier"],
|
| 108 |
-
"需求日期": ["需求", "required", "need date", "交期"],
|
| 109 |
-
"到货日期": ["到货", "arrival", "received"],
|
| 110 |
-
"请购日期": ["请购日期", "request", "requisition"]
|
| 111 |
-
}
|
| 112 |
-
for orig_col, c_clean in cleaned.items():
|
| 113 |
-
if orig_col in mapping:
|
| 114 |
-
continue
|
| 115 |
-
low = c_clean.lower()
|
| 116 |
-
for std, keys in keyword_map.items():
|
| 117 |
-
for k in keys:
|
| 118 |
-
if k in low:
|
| 119 |
-
mapping[orig_col] = std
|
| 120 |
-
matched[orig_col] = std
|
| 121 |
-
break
|
| 122 |
-
if orig_col in mapping:
|
| 123 |
-
break
|
| 124 |
-
|
| 125 |
-
df = df.rename(columns=mapping)
|
| 126 |
-
return df, matched
|
| 127 |
|
| 128 |
def _find_header_row(path: str, must_have: List[str] = None, try_rows: int = 10) -> int:
|
| 129 |
must_have = must_have or ["物料编码", "主数量"]
|
|
@@ -132,18 +97,12 @@ def _find_header_row(path: str, must_have: List[str] = None, try_rows: int = 10)
|
|
| 132 |
df_try = pd.read_excel(path, header=r, nrows=1)
|
| 133 |
except Exception:
|
| 134 |
continue
|
| 135 |
-
cols = [
|
| 136 |
-
|
| 137 |
-
ok = False
|
| 138 |
-
for m in must_have:
|
| 139 |
-
mm = m.lower()
|
| 140 |
-
if any(mm in c for c in lowcols) or any(c == mm for c in lowcols):
|
| 141 |
-
ok = True
|
| 142 |
-
if ok:
|
| 143 |
return r
|
| 144 |
return 0
|
| 145 |
|
| 146 |
-
def read_system_export(path: str) ->
|
| 147 |
header_row = _find_header_row(path)
|
| 148 |
try:
|
| 149 |
df = pd.read_excel(path, header=header_row)
|
|
@@ -151,77 +110,39 @@ def read_system_export(path: str) -> Tuple[pd.DataFrame, List[str]]:
|
|
| 151 |
df = pd.read_excel(path, header=0)
|
| 152 |
|
| 153 |
df = df.dropna(axis=1, how="all")
|
| 154 |
-
df
|
| 155 |
-
|
| 156 |
-
df, matched = _normalize_columns(df)
|
| 157 |
|
| 158 |
-
# 基本清洗
|
| 159 |
if "物料名称" in df.columns:
|
| 160 |
df["物料名称"] = df["物料名称"].astype(str).fillna('')
|
| 161 |
-
contains_e = df["物料名称"].str.contains("鹅"
|
| 162 |
-
contains_ya = df["物料名称"].str.contains("鸭"
|
| 163 |
-
contains_huazhu = df["物料名称"].str.contains("华住"
|
| 164 |
-
contains_huazhu_special = df["物料名称"].str.contains("华住专用"
|
| 165 |
to_remove = (contains_e | contains_ya | contains_huazhu) & ~contains_huazhu_special
|
| 166 |
-
|
| 167 |
-
df = df[~to_remove]
|
| 168 |
|
| 169 |
for c in ["请购日期","需求日期","到货日期","入库日期","计划到货日期"]:
|
| 170 |
if c in df.columns:
|
| 171 |
df[c] = pd.to_datetime(df[c], errors="coerce")
|
| 172 |
-
|
| 173 |
for c in ["主数量","到货主数量","入库主数量"]:
|
| 174 |
if c in df.columns:
|
| 175 |
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0)
|
| 176 |
|
| 177 |
df = df.dropna(how="all")
|
| 178 |
-
return df
|
| 179 |
|
| 180 |
def _first_nonnull(series: pd.Series):
|
| 181 |
return series.dropna().iloc[0] if not series.dropna().empty else None
|
| 182 |
|
| 183 |
-
def
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
返回 (group_keys, warnings)
|
| 189 |
-
"""
|
| 190 |
-
std_candidates = ["请购单号","物料编码","物料名称","供应商"]
|
| 191 |
-
present = [c for c in std_candidates if c in df.columns]
|
| 192 |
-
warnings = []
|
| 193 |
-
if present:
|
| 194 |
-
# 至少保留物料编码或请购单号其一,若缺一则用其他字段补足
|
| 195 |
-
return present, warnings
|
| 196 |
-
|
| 197 |
-
# 尝试通过列名中包含关键词来识别(更宽松)
|
| 198 |
-
lowered = {c: c.lower() for c in df.columns}
|
| 199 |
-
found = []
|
| 200 |
-
for c in df.columns:
|
| 201 |
-
low = c.lower()
|
| 202 |
-
if any(k in low for k in ["po", "order", "请购", "采购"]):
|
| 203 |
-
found.append(c)
|
| 204 |
-
elif any(k in low for k in ["物料", "sku", "item", "料号", "code"]):
|
| 205 |
-
found.append(c)
|
| 206 |
-
if len(found) >= 2:
|
| 207 |
-
break
|
| 208 |
-
if found:
|
| 209 |
-
warnings.append(f"通过关键词推断分组字段: {found}")
|
| 210 |
-
return found, warnings
|
| 211 |
-
|
| 212 |
-
# 最后一招:挑选前两个非数值列作为分组键
|
| 213 |
-
non_numeric = [c for c in df.columns if df[c].dtype == object or pd.api.types.is_string_dtype(df[c])]
|
| 214 |
-
fallback = non_numeric[:2] if len(non_numeric) >= 2 else list(df.columns[:2])
|
| 215 |
-
warnings.append(f"未找到标准分组字段,采用回退列作为分组键: {fallback}")
|
| 216 |
-
return fallback, warnings
|
| 217 |
-
|
| 218 |
-
def aggregate_for_email(df: pd.DataFrame) -> Tuple[pd.DataFrame, List[str]]:
|
| 219 |
-
group_keys, warnings = _infer_group_keys(df)
|
| 220 |
if not group_keys:
|
| 221 |
-
|
| 222 |
-
raise RuntimeError("无法推断分组键,请检查输入表头。")
|
| 223 |
|
| 224 |
-
# 构造 agg map
|
| 225 |
agg_map = {
|
| 226 |
"主数量": _first_nonnull,
|
| 227 |
"请购日期": _first_nonnull,
|
|
@@ -232,19 +153,12 @@ def aggregate_for_email(df: pd.DataFrame) -> Tuple[pd.DataFrame, List[str]]:
|
|
| 232 |
"到货主数量": "sum",
|
| 233 |
"入库主数量": "sum"
|
| 234 |
}
|
|
|
|
| 235 |
final_agg_map = {k: v for k, v in agg_map.items() if k in df.columns}
|
| 236 |
|
| 237 |
-
# 防护:如果 group_keys 中包含不存在的列,过滤掉
|
| 238 |
-
group_keys = [g for g in group_keys if g in df.columns]
|
| 239 |
-
if not group_keys:
|
| 240 |
-
# 拜托使用第一列作为最低限度分组(保证不崩溃)
|
| 241 |
-
first_col = df.columns[0]
|
| 242 |
-
warnings.append(f"最终找不到任何已识别的分组字段,强制使用第一列 `{first_col}` 作为分组键。")
|
| 243 |
-
group_keys = [first_col]
|
| 244 |
-
|
| 245 |
grouped = df.groupby(group_keys, dropna=False).agg(final_agg_map).reset_index()
|
| 246 |
grouped["目前进度"] = grouped.apply(_calc_progress_row, axis=1)
|
| 247 |
-
|
| 248 |
def get_sort_key(status_text):
|
| 249 |
if "逾期" in status_text and "未到货" in status_text:
|
| 250 |
return 1
|
|
@@ -258,19 +172,19 @@ def aggregate_for_email(df: pd.DataFrame) -> Tuple[pd.DataFrame, List[str]]:
|
|
| 258 |
|
| 259 |
grouped['sort_key'] = grouped['目前进度'].apply(get_sort_key)
|
| 260 |
grouped = grouped.sort_values(by='sort_key').drop(columns=['sort_key'])
|
| 261 |
-
|
| 262 |
if "计划到货日期" in grouped.columns:
|
| 263 |
grouped = grouped.drop(columns=["计划到货日期"])
|
| 264 |
|
| 265 |
final_cols = [col for col in EMAIL_COLS_DEFAULT if col in grouped.columns]
|
| 266 |
-
final = grouped[final_cols]
|
| 267 |
|
| 268 |
date_cols_to_format = ["请购日期", "需求日期", "到货日期", "入库日期"]
|
| 269 |
for col in date_cols_to_format:
|
| 270 |
if col in final.columns:
|
| 271 |
final[col] = pd.to_datetime(final[col], errors='coerce').dt.strftime('%Y-%m-%d')
|
| 272 |
|
| 273 |
-
return final
|
| 274 |
|
| 275 |
def _calc_progress_row(row: pd.Series) -> str:
|
| 276 |
today = pd.Timestamp(_today())
|
|
@@ -285,7 +199,7 @@ def _calc_progress_row(row: pd.Series) -> str:
|
|
| 285 |
|
| 286 |
parts: List[str] = []
|
| 287 |
shortage = max(0.0, main_qty - arr_qty)
|
| 288 |
-
|
| 289 |
if arr_qty > 0:
|
| 290 |
parts.append(f"部分到货 缺货{shortage:g}米")
|
| 291 |
elif pd.isna(arrival_date) or arr_qty == 0:
|
|
@@ -297,18 +211,15 @@ def _calc_progress_row(row: pd.Series) -> str:
|
|
| 297 |
parts.append(f"已逾期{overdue_days}天")
|
| 298 |
|
| 299 |
if pd.notna(plan_arrival):
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
except Exception:
|
| 305 |
-
pass
|
| 306 |
-
|
| 307 |
if not parts:
|
| 308 |
if pd.notna(demand_date) and today <= pd.Timestamp(demand_date.date()):
|
| 309 |
return "未到货(未到期)"
|
| 310 |
return "处理中"
|
| 311 |
-
|
| 312 |
return ";".join(parts)
|
| 313 |
|
| 314 |
def _find_latest_input(input_dir: str) -> Optional[str]:
|
|
@@ -323,13 +234,13 @@ def _find_latest_input(input_dir: str) -> Optional[str]:
|
|
| 323 |
def _df_to_styled_excel_bytes(df: pd.DataFrame) -> bytes:
|
| 324 |
bio = BytesIO()
|
| 325 |
writer = pd.ExcelWriter(bio, engine='xlsxwriter')
|
| 326 |
-
|
| 327 |
sheet_name = '采购执行表'
|
| 328 |
df.fillna('').to_excel(writer, sheet_name=sheet_name, index=False)
|
| 329 |
-
|
| 330 |
workbook = writer.book
|
| 331 |
worksheet = writer.sheets[sheet_name]
|
| 332 |
-
|
| 333 |
header_format = workbook.add_format({'bold': True, 'font_name': 'Arial', 'font_size': 10, 'border': 1, 'align': 'center', 'valign': 'vcenter'})
|
| 334 |
default_format = workbook.add_format({'font_name': 'Arial', 'font_size': 10, 'border': 1})
|
| 335 |
overdue_format = workbook.add_format({'font_name': 'Arial', 'font_size': 10, 'border': 1, 'font_color': 'red'})
|
|
@@ -338,7 +249,7 @@ def _df_to_styled_excel_bytes(df: pd.DataFrame) -> bytes:
|
|
| 338 |
worksheet.write(0, col_num, value, header_format)
|
| 339 |
|
| 340 |
worksheet.conditional_format(1, 0, len(df), len(df.columns)-1, {'type': 'no_blanks', 'format': default_format})
|
| 341 |
-
|
| 342 |
try:
|
| 343 |
progress_col_idx = df.columns.get_loc('目前进度')
|
| 344 |
for row_num in range(len(df)):
|
|
@@ -350,9 +261,9 @@ def _df_to_styled_excel_bytes(df: pd.DataFrame) -> bytes:
|
|
| 350 |
|
| 351 |
for i, col in enumerate(df.columns):
|
| 352 |
column_len = df[col].astype(str).str.len().max()
|
| 353 |
-
column_len = max(column_len, len(col) * 2)
|
| 354 |
-
worksheet.set_column(i, i, min(column_len, 40))
|
| 355 |
-
|
| 356 |
writer.close()
|
| 357 |
bio.seek(0)
|
| 358 |
return bio.read()
|
|
@@ -368,55 +279,71 @@ def _build_html_body(df: pd.DataFrame, title: str) -> str:
|
|
| 368 |
</body></html>"""
|
| 369 |
return html
|
| 370 |
|
|
|
|
| 371 |
def _send_email_via_resend(subject: str, html_body: str, attachment_bytes: Optional[bytes], attachment_name: str) -> Tuple[bool, str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
if not (RESEND_API_KEY and FROM_EMAIL and TO_EMAIL):
|
| 373 |
return False, "缺少 Resend 配置(RESEND_API_KEY / FROM_EMAIL / TO_EMAIL)"
|
| 374 |
-
|
| 375 |
-
if FROM_NAME:
|
| 376 |
-
from_field = f"{FROM_NAME} <{FROM_EMAIL}>"
|
| 377 |
-
else:
|
| 378 |
-
from_field = FROM_EMAIL
|
| 379 |
-
|
| 380 |
url = "https://api.resend.com/emails"
|
| 381 |
-
headers = {
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
"subject": subject,
|
| 390 |
-
"html": html_body,
|
| 391 |
-
"headers": {"Reply-To": FROM_EMAIL, "Sender": FROM_EMAIL}
|
| 392 |
-
}
|
| 393 |
-
|
| 394 |
if attachment_bytes is not None:
|
| 395 |
-
payload["attachments"] = [
|
| 396 |
-
|
| 397 |
-
]
|
| 398 |
-
|
| 399 |
try:
|
|
|
|
| 400 |
resp = requests.post(url, headers=headers, data=json.dumps(payload, ensure_ascii=False).encode('utf-8'), timeout=30)
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
except requests.exceptions.RequestException as e:
|
| 406 |
return False, f"邮件发送请求失败: {e}"
|
| 407 |
|
|
|
|
| 408 |
def _send_email_via_smtp(subject: str, html_body: str, attachment_bytes: Optional[bytes], attachment_name: str) -> Tuple[bool, str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
if not (SMTP_HOST and SMTP_USER and SMTP_PASS and FROM_EMAIL and TO_EMAIL):
|
| 410 |
return False, "缺少 SMTP 配置(SMTP_HOST / SMTP_USER / SMTP_PASS / FROM_EMAIL / TO_EMAIL)"
|
| 411 |
|
| 412 |
try:
|
| 413 |
msg = EmailMessage()
|
| 414 |
-
sender =
|
|
|
|
|
|
|
| 415 |
msg["From"] = sender
|
| 416 |
msg["To"] = TO_EMAIL
|
| 417 |
msg["Subject"] = subject
|
| 418 |
-
msg
|
| 419 |
-
msg.set_content("这是 HTML 邮件,请在支持 HTML 的客户端查看。")
|
| 420 |
msg.add_alternative(html_body, subtype='html')
|
| 421 |
|
| 422 |
if attachment_bytes is not None:
|
|
@@ -427,11 +354,11 @@ def _send_email_via_smtp(subject: str, html_body: str, attachment_bytes: Optiona
|
|
| 427 |
smtp.login(SMTP_USER, SMTP_PASS)
|
| 428 |
smtp.send_message(msg)
|
| 429 |
smtp.quit()
|
| 430 |
-
return True, "SMTP 发送成功"
|
| 431 |
except Exception as e:
|
| 432 |
-
return False, f"SMTP 发送失败: {e}"
|
| 433 |
|
| 434 |
-
# ====== 主流程 ======
|
| 435 |
def run_once(file_path: Optional[str] = None) -> dict:
|
| 436 |
try:
|
| 437 |
if file_path is None:
|
|
@@ -439,35 +366,29 @@ def run_once(file_path: Optional[str] = None) -> dict:
|
|
| 439 |
if not file_path:
|
| 440 |
return {"ok": False, "msg": f"未在 {INPUT_DIR} 找到Excel输入文件"}
|
| 441 |
|
| 442 |
-
raw
|
| 443 |
-
final
|
| 444 |
-
|
| 445 |
-
combined_warnings = []
|
| 446 |
-
if matched:
|
| 447 |
-
combined_warnings.append(f"匹配到的列映射: {matched}")
|
| 448 |
-
if warnings:
|
| 449 |
-
combined_warnings.extend(warnings)
|
| 450 |
-
|
| 451 |
out_name = f"邮件发送的格式_{datetime.now().strftime('%Y%m%d')}.xlsx"
|
| 452 |
out_path = os.path.join(OUTPUT_DIR, out_name)
|
| 453 |
-
|
| 454 |
attach = _df_to_styled_excel_bytes(final)
|
| 455 |
final.to_excel(out_path, index=False)
|
| 456 |
|
| 457 |
subject = f"采购执行表自动推送 {datetime.now().date()}"
|
| 458 |
html = _build_html_body(final, title=f"采购执行表({datetime.now().date()})")
|
| 459 |
-
|
| 460 |
ok, info = _send_email_via_resend(subject, html, attachment_bytes=attach, attachment_name=out_name)
|
| 461 |
|
|
|
|
| 462 |
if not ok and SMTP_HOST and SMTP_USER and SMTP_PASS:
|
| 463 |
ok_smtp, info_smtp = _send_email_via_smtp(subject, html, attachment_bytes=attach, attachment_name=out_name)
|
| 464 |
if ok_smtp:
|
| 465 |
-
return {"ok": True, "msg": "通过 SMTP 回退发送成功", "input": file_path, "output": out_path, "rows": len(final), "resend_info": info, "smtp_info": info_smtp
|
| 466 |
else:
|
| 467 |
-
return {"ok": False, "msg": f"Resend 失败: {info};SMTP 回退也失败: {info_smtp}", "input": file_path, "output": out_path, "rows": len(final)
|
| 468 |
-
|
| 469 |
-
return {"ok": ok, "msg": "邮件发送成功" if ok else f"邮件发送失败:{info}", "input": file_path, "output": out_path, "rows": len(final)
|
| 470 |
-
|
| 471 |
except Exception as e:
|
| 472 |
import traceback
|
| 473 |
return {"ok": False, "msg": f"处理文件时发生严重错误: {e}", "traceback": traceback.format_exc()}
|
|
@@ -476,7 +397,7 @@ def main():
|
|
| 476 |
arg_file = sys.argv[1] if len(sys.argv) > 1 else None
|
| 477 |
result = run_once(arg_file)
|
| 478 |
print(json.dumps(result, ensure_ascii=False))
|
| 479 |
-
|
| 480 |
if not result.get("ok"):
|
| 481 |
sys.exit(1)
|
| 482 |
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
"""
|
| 4 |
+
process_report.py
|
| 5 |
+
读取最新或指定的“系统导出格式”Excel,按业务口径聚合为“邮件发送的格式”,
|
| 6 |
+
并通过 Resend 发送邮件(HTML表格 + Excel附件)。
|
| 7 |
+
新增:检测 Resend 返回中可能导致 Gmail 退信的错误信息,并在失败时可选通过 SMTP 回退发送(仅回退)。
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import os
|
|
|
|
| 29 |
|
| 30 |
RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
|
| 31 |
FROM_EMAIL = os.environ.get("FROM_EMAIL")
|
|
|
|
| 32 |
TO_EMAIL = os.environ.get("TO_EMAIL")
|
| 33 |
TIMEZONE = os.environ.get("TZ", "Asia/Shanghai")
|
| 34 |
|
| 35 |
+
# ========== 新增(可选 SMTP 回退) ==========
|
| 36 |
+
# 若希望在 Resend 失败(或怀疑被 Gmail 退信)时自动尝试通过 SMTP 发送,请在环境变量中配置:
|
| 37 |
+
# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
|
| 38 |
+
# 例如:SMTP_HOST=smtp.gmail.com, SMTP_PORT=587, SMTP_USER=your@gmail.com, SMTP_PASS=your_app_password
|
| 39 |
SMTP_HOST = os.environ.get("SMTP_HOST")
|
| 40 |
SMTP_PORT = int(os.environ.get("SMTP_PORT", 587) or 587)
|
| 41 |
SMTP_USER = os.environ.get("SMTP_USER")
|
| 42 |
SMTP_PASS = os.environ.get("SMTP_PASS")
|
| 43 |
+
# ===========================================
|
| 44 |
|
| 45 |
+
# ====== 业务相关字段 ======
|
| 46 |
ALIASES = {
|
| 47 |
+
"请购日期": ["请购日期", "请购日", "申请日期"],
|
| 48 |
+
"请购单号": ["请购单号", "请购单编号", "申请单号"],
|
| 49 |
+
"物料编码": ["物料编码", "物料号", "物料代码"],
|
| 50 |
+
"物料名称": ["物料名称", "品名", "名称"],
|
| 51 |
+
"纱支密度": ["纱支密度", "纱支/密度", "纱支 密度"],
|
| 52 |
+
"门幅(CM)": ["门幅(CM)", "门幅(CM)", "门幅cm", "门幅"],
|
| 53 |
+
"颜色": ["颜色", "色号/颜色", "色号"],
|
| 54 |
+
"主单位": ["主单位", "单位"],
|
| 55 |
+
"主数量": ["主数量", "数量", "请购数量"],
|
| 56 |
+
"需求日期": ["需求日期", "需求日", "交期", "要求到货日期"],
|
| 57 |
+
"供应商": ["供应商", "供货商", "供应商名称"],
|
| 58 |
+
"到货日期": ["到货日期", "实到日期", "收货日期"],
|
| 59 |
+
"到货主数量": ["到货主数量", "到货数量", "实到数量"],
|
| 60 |
+
"入库日期": ["入库日期", "入库日"],
|
| 61 |
+
"入库主数量": ["入库主数量", "入库数量"],
|
| 62 |
+
"计划到货日期": ["计划到货日期", "预计到货日期", "承诺到货日期", "计划到货日"],
|
| 63 |
}
|
| 64 |
|
| 65 |
EMAIL_COLS_DEFAULT = [
|
|
|
|
| 67 |
"主数量","需求日期","供应商","到货日期","到货主数量","入库日期","入库主数量","目前进度"
|
| 68 |
]
|
| 69 |
|
| 70 |
+
TEMPLATE_CANDIDATES = [
|
| 71 |
+
"/workspace/邮件发送的格式.xlsx",
|
| 72 |
+
"/workspace/templates/邮件发送的格式.xlsx",
|
| 73 |
+
"/app/邮件发送的格式.xlsx",
|
| 74 |
+
"/app/templates/邮件发送的格式.xlsx",
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
# ====== 工具函数 ======
|
| 79 |
def _today() -> date:
|
| 80 |
return datetime.now().date()
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
|
| 83 |
+
mapped = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
for std_name, variants in ALIASES.items():
|
| 85 |
+
for v in df.columns:
|
| 86 |
+
v_clean = str(v).strip()
|
| 87 |
+
if v_clean in variants:
|
| 88 |
+
mapped[v] = std_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
break
|
| 90 |
+
df = df.rename(columns=mapped)
|
| 91 |
+
return df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
def _find_header_row(path: str, must_have: List[str] = None, try_rows: int = 10) -> int:
|
| 94 |
must_have = must_have or ["物料编码", "主数量"]
|
|
|
|
| 97 |
df_try = pd.read_excel(path, header=r, nrows=1)
|
| 98 |
except Exception:
|
| 99 |
continue
|
| 100 |
+
cols = [str(c).strip() for c in df_try.columns]
|
| 101 |
+
if all(any(m in c for c in cols) or m in cols for m in must_have):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
return r
|
| 103 |
return 0
|
| 104 |
|
| 105 |
+
def read_system_export(path: str) -> pd.DataFrame:
|
| 106 |
header_row = _find_header_row(path)
|
| 107 |
try:
|
| 108 |
df = pd.read_excel(path, header=header_row)
|
|
|
|
| 110 |
df = pd.read_excel(path, header=0)
|
| 111 |
|
| 112 |
df = df.dropna(axis=1, how="all")
|
| 113 |
+
df = _normalize_columns(df)
|
|
|
|
|
|
|
| 114 |
|
|
|
|
| 115 |
if "物料名称" in df.columns:
|
| 116 |
df["物料名称"] = df["物料名称"].astype(str).fillna('')
|
| 117 |
+
contains_e = df["物料名称"].str.contains("鹅")
|
| 118 |
+
contains_ya = df["物料名称"].str.contains("鸭")
|
| 119 |
+
contains_huazhu = df["物料名称"].str.contains("华住")
|
| 120 |
+
contains_huazhu_special = df["物料名称"].str.contains("华住专用")
|
| 121 |
to_remove = (contains_e | contains_ya | contains_huazhu) & ~contains_huazhu_special
|
| 122 |
+
df = df[~to_remove]
|
|
|
|
| 123 |
|
| 124 |
for c in ["请购日期","需求日期","到货日期","入库日期","计划到货日期"]:
|
| 125 |
if c in df.columns:
|
| 126 |
df[c] = pd.to_datetime(df[c], errors="coerce")
|
| 127 |
+
|
| 128 |
for c in ["主数量","到货主数量","入库主数量"]:
|
| 129 |
if c in df.columns:
|
| 130 |
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0)
|
| 131 |
|
| 132 |
df = df.dropna(how="all")
|
| 133 |
+
return df
|
| 134 |
|
| 135 |
def _first_nonnull(series: pd.Series):
|
| 136 |
return series.dropna().iloc[0] if not series.dropna().empty else None
|
| 137 |
|
| 138 |
+
def aggregate_for_email(df: pd.DataFrame) -> pd.DataFrame:
|
| 139 |
+
group_keys = [k for k in [
|
| 140 |
+
"请购单号","物料编码","物料名称","纱支密度","门幅(CM)","颜色","主单位","供应商"
|
| 141 |
+
] if k in df.columns]
|
| 142 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
if not group_keys:
|
| 144 |
+
raise RuntimeError("找不到用于分组的关键字段(如 请购单号/物料编码 等),请检查导入的表头。")
|
|
|
|
| 145 |
|
|
|
|
| 146 |
agg_map = {
|
| 147 |
"主数量": _first_nonnull,
|
| 148 |
"请购日期": _first_nonnull,
|
|
|
|
| 153 |
"到货主数量": "sum",
|
| 154 |
"入库主数量": "sum"
|
| 155 |
}
|
| 156 |
+
|
| 157 |
final_agg_map = {k: v for k, v in agg_map.items() if k in df.columns}
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
grouped = df.groupby(group_keys, dropna=False).agg(final_agg_map).reset_index()
|
| 160 |
grouped["目前进度"] = grouped.apply(_calc_progress_row, axis=1)
|
| 161 |
+
|
| 162 |
def get_sort_key(status_text):
|
| 163 |
if "逾期" in status_text and "未到货" in status_text:
|
| 164 |
return 1
|
|
|
|
| 172 |
|
| 173 |
grouped['sort_key'] = grouped['目前进度'].apply(get_sort_key)
|
| 174 |
grouped = grouped.sort_values(by='sort_key').drop(columns=['sort_key'])
|
| 175 |
+
|
| 176 |
if "计划到货日期" in grouped.columns:
|
| 177 |
grouped = grouped.drop(columns=["计划到货日期"])
|
| 178 |
|
| 179 |
final_cols = [col for col in EMAIL_COLS_DEFAULT if col in grouped.columns]
|
| 180 |
+
final = grouped[final_cols]
|
| 181 |
|
| 182 |
date_cols_to_format = ["请购日期", "需求日期", "到货日期", "入库日期"]
|
| 183 |
for col in date_cols_to_format:
|
| 184 |
if col in final.columns:
|
| 185 |
final[col] = pd.to_datetime(final[col], errors='coerce').dt.strftime('%Y-%m-%d')
|
| 186 |
|
| 187 |
+
return final
|
| 188 |
|
| 189 |
def _calc_progress_row(row: pd.Series) -> str:
|
| 190 |
today = pd.Timestamp(_today())
|
|
|
|
| 199 |
|
| 200 |
parts: List[str] = []
|
| 201 |
shortage = max(0.0, main_qty - arr_qty)
|
| 202 |
+
|
| 203 |
if arr_qty > 0:
|
| 204 |
parts.append(f"部分到货 缺货{shortage:g}米")
|
| 205 |
elif pd.isna(arrival_date) or arr_qty == 0:
|
|
|
|
| 211 |
parts.append(f"已逾期{overdue_days}天")
|
| 212 |
|
| 213 |
if pd.notna(plan_arrival):
|
| 214 |
+
days_ahead = (plan_arrival.date() - today.date()).days
|
| 215 |
+
if 0 <= days_ahead <= 7:
|
| 216 |
+
parts.append(f"未来7天到货(计划{str(plan_arrival.date())})")
|
| 217 |
+
|
|
|
|
|
|
|
|
|
|
| 218 |
if not parts:
|
| 219 |
if pd.notna(demand_date) and today <= pd.Timestamp(demand_date.date()):
|
| 220 |
return "未到货(未到期)"
|
| 221 |
return "处理中"
|
| 222 |
+
|
| 223 |
return ";".join(parts)
|
| 224 |
|
| 225 |
def _find_latest_input(input_dir: str) -> Optional[str]:
|
|
|
|
| 234 |
def _df_to_styled_excel_bytes(df: pd.DataFrame) -> bytes:
|
| 235 |
bio = BytesIO()
|
| 236 |
writer = pd.ExcelWriter(bio, engine='xlsxwriter')
|
| 237 |
+
|
| 238 |
sheet_name = '采购执行表'
|
| 239 |
df.fillna('').to_excel(writer, sheet_name=sheet_name, index=False)
|
| 240 |
+
|
| 241 |
workbook = writer.book
|
| 242 |
worksheet = writer.sheets[sheet_name]
|
| 243 |
+
|
| 244 |
header_format = workbook.add_format({'bold': True, 'font_name': 'Arial', 'font_size': 10, 'border': 1, 'align': 'center', 'valign': 'vcenter'})
|
| 245 |
default_format = workbook.add_format({'font_name': 'Arial', 'font_size': 10, 'border': 1})
|
| 246 |
overdue_format = workbook.add_format({'font_name': 'Arial', 'font_size': 10, 'border': 1, 'font_color': 'red'})
|
|
|
|
| 249 |
worksheet.write(0, col_num, value, header_format)
|
| 250 |
|
| 251 |
worksheet.conditional_format(1, 0, len(df), len(df.columns)-1, {'type': 'no_blanks', 'format': default_format})
|
| 252 |
+
|
| 253 |
try:
|
| 254 |
progress_col_idx = df.columns.get_loc('目前进度')
|
| 255 |
for row_num in range(len(df)):
|
|
|
|
| 261 |
|
| 262 |
for i, col in enumerate(df.columns):
|
| 263 |
column_len = df[col].astype(str).str.len().max()
|
| 264 |
+
column_len = max(column_len, len(col) * 2)
|
| 265 |
+
worksheet.set_column(i, i, min(column_len, 40))
|
| 266 |
+
|
| 267 |
writer.close()
|
| 268 |
bio.seek(0)
|
| 269 |
return bio.read()
|
|
|
|
| 279 |
</body></html>"""
|
| 280 |
return html
|
| 281 |
|
| 282 |
+
# ====== 增强的 Resend 发送函数(最小修改) ======
|
| 283 |
def _send_email_via_resend(subject: str, html_body: str, attachment_bytes: Optional[bytes], attachment_name: str) -> Tuple[bool, str]:
|
| 284 |
+
"""
|
| 285 |
+
使用 Resend API 发送邮件(带附件 base64)。
|
| 286 |
+
如果 Resend 返回错误,并且怀疑为 Gmail 退信/被阻止,会把详细信息放入返回字符串中。
|
| 287 |
+
"""
|
| 288 |
if not (RESEND_API_KEY and FROM_EMAIL and TO_EMAIL):
|
| 289 |
return False, "缺少 Resend 配置(RESEND_API_KEY / FROM_EMAIL / TO_EMAIL)"
|
| 290 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
url = "https://api.resend.com/emails"
|
| 292 |
+
headers = {"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json"}
|
| 293 |
+
|
| 294 |
+
# 尽量把 From 用标准格式传过去(某些邮件网关更容易接受 Name <email>)
|
| 295 |
+
from_field = FROM_EMAIL
|
| 296 |
+
if "FROM_NAME" in os.environ and os.environ.get("FROM_NAME"):
|
| 297 |
+
from_field = f"{os.environ.get('FROM_NAME')} <{FROM_EMAIL}>"
|
| 298 |
+
|
| 299 |
+
payload = {"from": from_field, "to": [TO_EMAIL], "subject": subject, "html": html_body}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
if attachment_bytes is not None:
|
| 301 |
+
payload["attachments"] = [{"filename": attachment_name, "content": base64.b64encode(attachment_bytes).decode("utf-8")}]
|
| 302 |
+
|
|
|
|
|
|
|
| 303 |
try:
|
| 304 |
+
# 保留原有行为:请求 Resend
|
| 305 |
resp = requests.post(url, headers=headers, data=json.dumps(payload, ensure_ascii=False).encode('utf-8'), timeout=30)
|
| 306 |
+
# 判断 success
|
| 307 |
+
if 200 <= resp.status_code < 300:
|
| 308 |
+
return True, resp.text
|
| 309 |
+
# 非 2xx -> 视为失败,解析可能的退信/拒收信息
|
| 310 |
+
resp_text = ""
|
| 311 |
+
try:
|
| 312 |
+
resp_text = resp.text
|
| 313 |
+
except Exception:
|
| 314 |
+
resp_text = repr(resp.content)
|
| 315 |
+
# 检查常见 Gmail/退信关键词(简单检测)
|
| 316 |
+
lowered = resp_text.lower()
|
| 317 |
+
gmail_related = any(k in lowered for k in [
|
| 318 |
+
"550", "5.1.1", "user unknown", "mailbox unavailable", "message rejected", "delivery failed", "mailbox not found",
|
| 319 |
+
"gmail", "google", "suppression", "bounce", "blocked", "denied"
|
| 320 |
+
])
|
| 321 |
+
extra = ""
|
| 322 |
+
if gmail_related:
|
| 323 |
+
extra = ";怀疑为 Gmail/目标邮箱���信或被阻断(响应中包含疑似退信/阻断关键词)。"
|
| 324 |
+
extra += " 请检查 Resend 控制台的 Suppression、发件人验证(SPF/DKIM),以及邮件退回通知(Mail Delivery Subsystem)。"
|
| 325 |
+
return False, f"Resend 返回失败状态 {resp.status_code}: {resp_text}{extra}"
|
| 326 |
except requests.exceptions.RequestException as e:
|
| 327 |
return False, f"邮件发送请求失败: {e}"
|
| 328 |
|
| 329 |
+
# ====== 新增:SMTP 回退实现(不会默认使用,仅在 Resend 失败且环境定义了 SMTP 配置时尝试) ======
|
| 330 |
def _send_email_via_smtp(subject: str, html_body: str, attachment_bytes: Optional[bytes], attachment_name: str) -> Tuple[bool, str]:
|
| 331 |
+
"""
|
| 332 |
+
可选的 SMTP 回退。需要在环境变量中配置 SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS。
|
| 333 |
+
这一步仅作为回退,不改变你原有首选的 Resend 逻辑。
|
| 334 |
+
"""
|
| 335 |
if not (SMTP_HOST and SMTP_USER and SMTP_PASS and FROM_EMAIL and TO_EMAIL):
|
| 336 |
return False, "缺少 SMTP 配置(SMTP_HOST / SMTP_USER / SMTP_PASS / FROM_EMAIL / TO_EMAIL)"
|
| 337 |
|
| 338 |
try:
|
| 339 |
msg = EmailMessage()
|
| 340 |
+
sender = FROM_EMAIL
|
| 341 |
+
if "FROM_NAME" in os.environ and os.environ.get("FROM_NAME"):
|
| 342 |
+
sender = f"{os.environ.get('FROM_NAME')} <{FROM_EMAIL}>"
|
| 343 |
msg["From"] = sender
|
| 344 |
msg["To"] = TO_EMAIL
|
| 345 |
msg["Subject"] = subject
|
| 346 |
+
msg.set_content("这是 HTML 邮件(备用 SMTP 发送),请使用支持 HTML 的邮件客户端查看。")
|
|
|
|
| 347 |
msg.add_alternative(html_body, subtype='html')
|
| 348 |
|
| 349 |
if attachment_bytes is not None:
|
|
|
|
| 354 |
smtp.login(SMTP_USER, SMTP_PASS)
|
| 355 |
smtp.send_message(msg)
|
| 356 |
smtp.quit()
|
| 357 |
+
return True, "SMTP 发送成功(作为回退)"
|
| 358 |
except Exception as e:
|
| 359 |
+
return False, f"SMTP 发送失败(回退): {e}"
|
| 360 |
|
| 361 |
+
# ====== 主流程(保持原有流程不变,仅在发送失败时尝试回退) ======
|
| 362 |
def run_once(file_path: Optional[str] = None) -> dict:
|
| 363 |
try:
|
| 364 |
if file_path is None:
|
|
|
|
| 366 |
if not file_path:
|
| 367 |
return {"ok": False, "msg": f"未在 {INPUT_DIR} 找到Excel输入文件"}
|
| 368 |
|
| 369 |
+
raw = read_system_export(file_path)
|
| 370 |
+
final = aggregate_for_email(raw)
|
| 371 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
out_name = f"邮件发送的格式_{datetime.now().strftime('%Y%m%d')}.xlsx"
|
| 373 |
out_path = os.path.join(OUTPUT_DIR, out_name)
|
| 374 |
+
|
| 375 |
attach = _df_to_styled_excel_bytes(final)
|
| 376 |
final.to_excel(out_path, index=False)
|
| 377 |
|
| 378 |
subject = f"采购执行表自动推送 {datetime.now().date()}"
|
| 379 |
html = _build_html_body(final, title=f"采购执行表({datetime.now().date()})")
|
|
|
|
| 380 |
ok, info = _send_email_via_resend(subject, html, attachment_bytes=attach, attachment_name=out_name)
|
| 381 |
|
| 382 |
+
# 如果 Resend 失败,且配置了 SMTP 回退,则尝试通过 SMTP 重发(仅回退)
|
| 383 |
if not ok and SMTP_HOST and SMTP_USER and SMTP_PASS:
|
| 384 |
ok_smtp, info_smtp = _send_email_via_smtp(subject, html, attachment_bytes=attach, attachment_name=out_name)
|
| 385 |
if ok_smtp:
|
| 386 |
+
return {"ok": True, "msg": "Resend 失败,但通过 SMTP 回退发送成功", "input": file_path, "output": out_path, "rows": len(final), "resend_info": info, "smtp_info": info_smtp}
|
| 387 |
else:
|
| 388 |
+
return {"ok": False, "msg": f"Resend 失败: {info};SMTP 回退也失败: {info_smtp}", "input": file_path, "output": out_path, "rows": len(final)}
|
| 389 |
+
|
| 390 |
+
return {"ok": ok, "msg": "邮件发送成功" if ok else f"邮件发送失败:{info}", "input": file_path, "output": out_path, "rows": len(final)}
|
| 391 |
+
|
| 392 |
except Exception as e:
|
| 393 |
import traceback
|
| 394 |
return {"ok": False, "msg": f"处理文件时发生严重错误: {e}", "traceback": traceback.format_exc()}
|
|
|
|
| 397 |
arg_file = sys.argv[1] if len(sys.argv) > 1 else None
|
| 398 |
result = run_once(arg_file)
|
| 399 |
print(json.dumps(result, ensure_ascii=False))
|
| 400 |
+
|
| 401 |
if not result.get("ok"):
|
| 402 |
sys.exit(1)
|
| 403 |
|