Gabriel00A commited on
Commit
5e7e2ec
·
verified ·
1 Parent(s): 89130fe

Update process_report.py

Browse files
Files changed (1) hide show
  1. 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
- 与之前功能一致:读取 Excel -> 聚合 -> 生成 HTML + 附 -> 通过 Resend(或 SMTP 回退)发送
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
- # SMTP 回退(可选)
 
 
 
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
- "请购日期": ["请购日期", "请购日", "申请日期", "requisition date", "request date"],
47
- "请购单号": ["请购单号", "请购单编号", "申请单号", "采购单号", "PO", "po", "po number", "order no", "order number"],
48
- "物料编码": ["物料编码", "物料号", "物料代码", "item code", "sku", "item no", "material code"],
49
- "物料名称": ["物料名称", "品名", "名称", "description", "item name"],
50
- "纱支密度": ["纱支密度", "纱支", "yarn"],
51
- "门幅(CM)": ["门幅(CM)", "门幅(CM)", "门幅cm", "门幅", "width", "幅宽"],
52
- "颜色": ["颜色", "色号/颜色", "色号", "color"],
53
- "主单位": ["主单位", "单位", "unit"],
54
- "主数量": ["主数量", "数量", "请购数量", "qty", "quantity", "amount"],
55
- "需求日期": ["需求日期", "需求日", "交期", "要求到货日期", "required date", "need date"],
56
- "供应商": ["供应商", "供货商", "供应商名称", "vendor", "supplier"],
57
- "到货日期": ["到货日期", "实到日期", "收货日期", "arrival date", "received date"],
58
- "到货主数量": ["到货主数量", "到货数量", "实到数量", "received qty"],
59
- "入库日期": ["入库日期", "入库日", "stockin date"],
60
- "入库主数量": ["入库主数量", "入库数量", "stockin qty"],
61
- "计划到货日期": ["计划到货日期", "预计到货日期", "承诺到货日期", "计到货", "planned arrival"]
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 orig_col, c_clean in cleaned.items():
91
- if orig_col in mapping:
92
- continue
93
- for v in variants:
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 = [ _clean_col(c) for c in df_try.columns ]
136
- lowcols = [c.lower() for c in cols]
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) -> Tuple[pd.DataFrame, List[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.columns = [_clean_col(c) for c in df.columns]
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("鹅", na=False)
162
- contains_ya = df["物料名称"].str.contains("鸭", na=False)
163
- contains_huazhu = df["物料名称"].str.contains("华住", na=False)
164
- contains_huazhu_special = df["物料名称"].str.contains("华住专用", na=False)
165
  to_remove = (contains_e | contains_ya | contains_huazhu) & ~contains_huazhu_special
166
- if to_remove.any():
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, list(matched.items())
179
 
180
  def _first_nonnull(series: pd.Series):
181
  return series.dropna().iloc[0] if not series.dropna().empty else None
182
 
183
- def _infer_group_keys(df: pd.DataFrame) -> Tuple[List[str], List[str]]:
184
- """
185
- 尝试推断用于 group by 的列:
186
- 1) 优先使用已命名的标准字段(请购单号、物料编码、物料名称、供应商 等)
187
- 2) 若找不到,回退为:选择前两个非数值列(通常是编号/名称)
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
- # 这通常会发生,因为 _infer_group_keys 会回退
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] if final_cols else grouped
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, warnings
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
- try:
301
- days_ahead = (pd.to_datetime(plan_arrival).date() - today.date()).days
302
- if 0 <= days_ahead <= 7:
303
- parts.append(f"未来7天到货(计划{str(plan_arrival)[:10]})")
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
- "Authorization": f"Bearer {RESEND_API_KEY}",
383
- "Content-Type": "application/json"
384
- }
385
-
386
- payload = {
387
- "from": from_field,
388
- "to": [TO_EMAIL],
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
- {"filename": attachment_name, "content": base64.b64encode(attachment_bytes).decode("utf-8")}
397
- ]
398
-
399
  try:
 
400
  resp = requests.post(url, headers=headers, data=json.dumps(payload, ensure_ascii=False).encode('utf-8'), timeout=30)
401
- if resp.status_code >= 200 and resp.status_code < 300:
402
- return True, f"Resend OK: {resp.text}"
403
- else:
404
- return False, f"Resend 返回失败状态 {resp.status_code}: {resp.text}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = f"{FROM_NAME} <{FROM_EMAIL}>" if FROM_NAME else FROM_EMAIL
 
 
415
  msg["From"] = sender
416
  msg["To"] = TO_EMAIL
417
  msg["Subject"] = subject
418
- msg["Reply-To"] = FROM_EMAIL
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, matched = read_system_export(file_path)
443
- final, warnings = aggregate_for_email(raw)
444
- # 合并 matched/warnings 到返回信息,便于定位表头差异
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, "warnings": combined_warnings}
466
  else:
467
- return {"ok": False, "msg": f"Resend 失败: {info};SMTP 回退也失败: {info_smtp}", "input": file_path, "output": out_path, "rows": len(final), "warnings": combined_warnings}
468
-
469
- return {"ok": ok, "msg": "邮件发送成功" if ok else f"邮件发送失败:{info}", "input": file_path, "output": out_path, "rows": len(final), "warnings": combined_warnings}
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