Gabriel00A commited on
Commit
c8b0bcc
·
verified ·
1 Parent(s): 499a573

Update process_report.py

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