Spaces:
Paused
Paused
Update process_report.py
Browse files- process_report.py +57 -173
process_report.py
CHANGED
|
@@ -13,8 +13,7 @@ import json
|
|
| 13 |
import base64
|
| 14 |
from io import BytesIO
|
| 15 |
from typing import Optional, Tuple, List
|
| 16 |
-
|
| 17 |
-
from datetime import datetime, date, timedelta
|
| 18 |
|
| 19 |
import pandas as pd
|
| 20 |
import requests
|
|
@@ -30,15 +29,14 @@ FROM_EMAIL = os.environ.get("FROM_EMAIL")
|
|
| 30 |
TO_EMAIL = os.environ.get("TO_EMAIL")
|
| 31 |
TIMEZONE = os.environ.get("TZ", "Asia/Shanghai")
|
| 32 |
|
| 33 |
-
# ======
|
| 34 |
-
# 一些系统表可能列名略有差异,这里做一个“同义列名”匹配表
|
| 35 |
ALIASES = {
|
| 36 |
"请购日期": ["请购日期", "请购日", "申请日期"],
|
| 37 |
"请购单号": ["请购单号", "请购单编号", "申请单号"],
|
| 38 |
"物料编码": ["物料编码", "物料号", "物料代码"],
|
| 39 |
"物料名称": ["物料名称", "品名", "名称"],
|
| 40 |
"纱支密度": ["纱支密度", "纱支/密度", "纱支 密度"],
|
| 41 |
-
"门幅(CM)": ["门幅(CM)", "门幅(CM)", "门幅cm", "门幅"],
|
| 42 |
"颜色": ["颜色", "色号/颜色", "色号"],
|
| 43 |
"主单位": ["主单位", "单位"],
|
| 44 |
"主数量": ["主数量", "数量", "请购数量"],
|
|
@@ -51,31 +49,16 @@ ALIASES = {
|
|
| 51 |
"计划到货日期": ["计划到货日期", "预计到货日期", "承诺到货日期", "计划到货日"],
|
| 52 |
}
|
| 53 |
|
| 54 |
-
# “邮件发送的格式”列顺序,如果检测到模板文件,会按模板优先排序
|
| 55 |
EMAIL_COLS_DEFAULT = [
|
| 56 |
-
"请购日期","请购单号","物料编码","物料名称","纱支密度","门幅(CM)","颜色","主单位",
|
| 57 |
"主数量","需求日期","供应商","到货日期","到货主数量","入库日期","入库主数量","目前进度"
|
| 58 |
]
|
| 59 |
|
| 60 |
-
TEMPLATE_CANDIDATES = [
|
| 61 |
-
# 若你把模板Excel放进仓库根目录或 templates 目录,可被自动识别
|
| 62 |
-
"/workspace/邮件发送的格式.xlsx",
|
| 63 |
-
"/workspace/templates/邮件发送的格式.xlsx",
|
| 64 |
-
"/app/邮件发送的格式.xlsx",
|
| 65 |
-
"/app/templates/邮件发送的格式.xlsx",
|
| 66 |
-
]
|
| 67 |
-
|
| 68 |
-
|
| 69 |
# ====== 工具函数 ======
|
| 70 |
def _today() -> date:
|
| 71 |
-
# 用本地系统日期即可(Space容器时区一般是UTC;你可在 Space 里设 TZ 环境变量 + tzdata 以保证正确)
|
| 72 |
return datetime.now().date()
|
| 73 |
|
| 74 |
-
|
| 75 |
def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
|
| 76 |
-
"""
|
| 77 |
-
将 DataFrame 的列名映射到标准中文名(按 ALIASES)。
|
| 78 |
-
"""
|
| 79 |
mapped = {}
|
| 80 |
for std_name, variants in ALIASES.items():
|
| 81 |
for v in df.columns:
|
|
@@ -86,12 +69,7 @@ def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 86 |
df = df.rename(columns=mapped)
|
| 87 |
return df
|
| 88 |
|
| 89 |
-
|
| 90 |
def _find_header_row(path: str, must_have: List[str] = None, try_rows: int = 10) -> int:
|
| 91 |
-
"""
|
| 92 |
-
尝试在前 try_rows 行中找到包含关键列(如“物料编码”、“主数量”)的表头行。
|
| 93 |
-
找不到则返回 0。
|
| 94 |
-
"""
|
| 95 |
must_have = must_have or ["物料编码", "主数量"]
|
| 96 |
for r in range(try_rows):
|
| 97 |
try:
|
|
@@ -103,23 +81,16 @@ def _find_header_row(path: str, must_have: List[str] = None, try_rows: int = 10)
|
|
| 103 |
return r
|
| 104 |
return 0
|
| 105 |
|
| 106 |
-
|
| 107 |
def read_system_export(path: str) -> pd.DataFrame:
|
| 108 |
-
"""
|
| 109 |
-
读取“系统导出格式”Excel,并做列名标准化、空列丢弃、日期/数字类型转换。
|
| 110 |
-
"""
|
| 111 |
header_row = _find_header_row(path)
|
| 112 |
try:
|
| 113 |
df = pd.read_excel(path, header=header_row)
|
| 114 |
except Exception:
|
| 115 |
df = pd.read_excel(path, header=0)
|
| 116 |
|
| 117 |
-
# 丢掉全空列
|
| 118 |
df = df.dropna(axis=1, how="all")
|
| 119 |
-
# 标准化列名
|
| 120 |
df = _normalize_columns(df)
|
| 121 |
|
| 122 |
-
# --- 筛选逻辑 ---
|
| 123 |
if "物料名称" in df.columns:
|
| 124 |
df["物料名称"] = df["物料名称"].astype(str).fillna('')
|
| 125 |
contains_e = df["物料名称"].str.contains("鹅")
|
|
@@ -128,86 +99,59 @@ def read_system_export(path: str) -> pd.DataFrame:
|
|
| 128 |
contains_huazhu_special = df["物料名称"].str.contains("华住专用")
|
| 129 |
to_remove = (contains_e | contains_ya | contains_huazhu) & ~contains_huazhu_special
|
| 130 |
df = df[~to_remove]
|
| 131 |
-
# --- 筛选逻辑结束 ---
|
| 132 |
|
| 133 |
-
# 转日期
|
| 134 |
for c in ["请购日期","需求日期","到货日期","入库日期","计划到货日期"]:
|
| 135 |
if c in df.columns:
|
| 136 |
df[c] = pd.to_datetime(df[c], errors="coerce")
|
| 137 |
-
|
| 138 |
-
# 转数字
|
| 139 |
for c in ["主数量","到货主数量","入库主数量"]:
|
| 140 |
if c in df.columns:
|
| 141 |
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0)
|
| 142 |
|
| 143 |
-
# 去掉全空行
|
| 144 |
df = df.dropna(how="all")
|
| 145 |
return df
|
| 146 |
|
| 147 |
-
|
| 148 |
def _first_nonnull(series: pd.Series):
|
| 149 |
-
|
| 150 |
-
if pd.notna(v):
|
| 151 |
-
return v
|
| 152 |
-
return None
|
| 153 |
-
|
| 154 |
|
| 155 |
def aggregate_for_email(df: pd.DataFrame) -> pd.DataFrame:
|
| 156 |
-
"""
|
| 157 |
-
业务汇总规则
|
| 158 |
-
"""
|
| 159 |
group_keys = [k for k in [
|
| 160 |
-
"请购单号","物料编码","物料名称","纱支密度","门幅(CM)","颜色","主单位","供应商"
|
| 161 |
] if k in df.columns]
|
| 162 |
|
| 163 |
if not group_keys:
|
| 164 |
raise RuntimeError("找不到用于分组的关键字段(如 请购单号/物料编码 等),请检查导入的表头。")
|
| 165 |
|
| 166 |
-
agg_map = {
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
if
|
| 178 |
-
agg_map["计划到货日期"] = "max"
|
| 179 |
-
if "到货主数量" in df.columns:
|
| 180 |
-
agg_map["到货主数量"] = "sum"
|
| 181 |
-
if "入库主数量" in df.columns:
|
| 182 |
-
agg_map["入库主数量"] = "sum"
|
| 183 |
-
|
| 184 |
-
grouped = df.groupby(group_keys, dropna=False).agg(agg_map).reset_index()
|
| 185 |
-
|
| 186 |
-
# 计算 “目前进度”
|
| 187 |
-
grouped["目前进度"] = grouped.apply(_calc_progress_row, axis=1)
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
|
|
|
| 191 |
if "计划到货日期" in grouped.columns:
|
| 192 |
grouped = grouped.drop(columns=["计划到货日期"])
|
| 193 |
|
| 194 |
-
|
| 195 |
-
final =
|
| 196 |
-
|
| 197 |
-
# --- 新增修改 2:格式化所有日期列为短日期 ---
|
| 198 |
date_cols_to_format = ["请购日期", "需求日期", "到货日期", "入库日期"]
|
| 199 |
for col in date_cols_to_format:
|
| 200 |
if col in final.columns:
|
| 201 |
-
# NaT值(空日期)会被保留,非空日期会被格式化为 YYYY/M/D
|
| 202 |
final[col] = pd.to_datetime(final[col], errors='coerce').dt.strftime('%Y/%-m/%-d')
|
| 203 |
|
| 204 |
return final
|
| 205 |
|
| 206 |
-
|
| 207 |
def _calc_progress_row(row: pd.Series) -> str:
|
| 208 |
-
"""
|
| 209 |
-
目前进度的业务口径
|
| 210 |
-
"""
|
| 211 |
today = pd.Timestamp(_today())
|
| 212 |
main_qty = float(row.get("主数量", 0) or 0)
|
| 213 |
arr_qty = float(row.get("到货主数量", 0) or 0)
|
|
@@ -215,7 +159,9 @@ def _calc_progress_row(row: pd.Series) -> str:
|
|
| 215 |
arrival_date = row.get("到货日期", pd.NaT)
|
| 216 |
plan_arrival = row.get("计划到货日期", pd.NaT)
|
| 217 |
|
| 218 |
-
|
|
|
|
|
|
|
| 219 |
return "完全到货"
|
| 220 |
|
| 221 |
parts: List[str] = []
|
|
@@ -223,9 +169,8 @@ def _calc_progress_row(row: pd.Series) -> str:
|
|
| 223 |
|
| 224 |
if arr_qty > 0:
|
| 225 |
parts.append(f"部分到货 缺货{shortage:g}米")
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
parts.append("未到货")
|
| 229 |
|
| 230 |
if (pd.isna(arrival_date) or arr_qty < main_qty) and pd.notna(demand_date):
|
| 231 |
overdue_days = (today - demand_date).days
|
|
@@ -244,30 +189,6 @@ def _calc_progress_row(row: pd.Series) -> str:
|
|
| 244 |
|
| 245 |
return ";".join(parts)
|
| 246 |
|
| 247 |
-
|
| 248 |
-
def _order_like_template(df: pd.DataFrame) -> pd.DataFrame:
|
| 249 |
-
"""
|
| 250 |
-
若能找到“邮件发送的格式.xlsx”,按其表头顺序输出;否则用 EMAIL_COLS_DEFAULT。
|
| 251 |
-
"""
|
| 252 |
-
template_cols = None
|
| 253 |
-
for p in TEMPLATE_CANDIDATES:
|
| 254 |
-
if os.path.exists(p):
|
| 255 |
-
try:
|
| 256 |
-
tdf = pd.read_excel(p, nrows=0)
|
| 257 |
-
template_cols = list(map(str, tdf.columns))
|
| 258 |
-
break
|
| 259 |
-
except Exception:
|
| 260 |
-
continue
|
| 261 |
-
|
| 262 |
-
if template_cols is None:
|
| 263 |
-
template_cols = EMAIL_COLS_DEFAULT
|
| 264 |
-
|
| 265 |
-
front = [c for c in template_cols if c in df.columns]
|
| 266 |
-
tail = [c for c in df.columns if c not in front]
|
| 267 |
-
cols = front + tail
|
| 268 |
-
return df[cols].copy()
|
| 269 |
-
|
| 270 |
-
|
| 271 |
def _find_latest_input(input_dir: str) -> Optional[str]:
|
| 272 |
files = []
|
| 273 |
for pat in ("*.xlsx", "*.xls"):
|
|
@@ -277,70 +198,42 @@ def _find_latest_input(input_dir: str) -> Optional[str]:
|
|
| 277 |
files.sort(key=os.path.getmtime, reverse=True)
|
| 278 |
return files[0]
|
| 279 |
|
| 280 |
-
|
| 281 |
def _df_to_excel_bytes(df: pd.DataFrame) -> bytes:
|
| 282 |
bio = BytesIO()
|
| 283 |
df.to_excel(bio, index=False)
|
| 284 |
bio.seek(0)
|
| 285 |
return bio.read()
|
| 286 |
|
| 287 |
-
|
| 288 |
def _build_html_body(df: pd.DataFrame, title: str) -> str:
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
table {{ border-collapse: collapse; font-size: 13px; }}
|
| 296 |
-
table, th, td {{ border: 1px solid #ccc; padding: 6px; }}
|
| 297 |
-
th {{ background:#f6f6f6; }}
|
| 298 |
-
</style>
|
| 299 |
-
</head>
|
| 300 |
-
<body>
|
| 301 |
-
<h3>{title}</h3>
|
| 302 |
-
{table_html}
|
| 303 |
<p style="color:#666;">备注:此邮件由自动化系统生成。</p>
|
| 304 |
-
|
| 305 |
-
</html>"""
|
| 306 |
return html
|
| 307 |
|
| 308 |
-
|
| 309 |
-
def _send_email_via_resend(subject: str, html_body: str,
|
| 310 |
-
attachment_bytes: Optional[bytes],
|
| 311 |
-
attachment_name: str) -> Tuple[bool, str]:
|
| 312 |
if not (RESEND_API_KEY and FROM_EMAIL and TO_EMAIL):
|
| 313 |
return False, "缺少 Resend 配置(RESEND_API_KEY / FROM_EMAIL / TO_EMAIL)"
|
| 314 |
-
|
| 315 |
url = "https://api.resend.com/emails"
|
| 316 |
-
headers = {
|
| 317 |
-
|
| 318 |
-
"Content-Type": "application/json",
|
| 319 |
-
}
|
| 320 |
-
payload = {
|
| 321 |
-
"from": FROM_EMAIL,
|
| 322 |
-
"to": [TO_EMAIL],
|
| 323 |
-
"subject": subject,
|
| 324 |
-
"html": html_body,
|
| 325 |
-
}
|
| 326 |
if attachment_bytes is not None:
|
| 327 |
-
payload["attachments"] = [{
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
resp = requests.post(url, headers=headers, data=json.dumps(payload))
|
| 333 |
-
if resp.ok:
|
| 334 |
return True, resp.text
|
| 335 |
-
|
| 336 |
-
|
| 337 |
|
| 338 |
# ====== 主流程 ======
|
| 339 |
def run_once(file_path: Optional[str] = None) -> dict:
|
| 340 |
-
"""
|
| 341 |
-
单次处理:读入Excel -> 汇总 -> 生成输出 -> 发邮件。
|
| 342 |
-
返回一个 dict 给上层(便于 app.py 返回给前端)。
|
| 343 |
-
"""
|
| 344 |
try:
|
| 345 |
if file_path is None:
|
| 346 |
file_path = _find_latest_input(INPUT_DIR)
|
|
@@ -349,10 +242,9 @@ def run_once(file_path: Optional[str] = None) -> dict:
|
|
| 349 |
|
| 350 |
raw = read_system_export(file_path)
|
| 351 |
final = aggregate_for_email(raw)
|
| 352 |
-
|
| 353 |
out_name = f"邮件发送的格式_{datetime.now().strftime('%Y%m%d')}.xlsx"
|
| 354 |
out_path = os.path.join(OUTPUT_DIR, out_name)
|
| 355 |
-
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 356 |
final.to_excel(out_path, index=False)
|
| 357 |
|
| 358 |
subject = f"采购执行表自动推送 {datetime.now().date()}"
|
|
@@ -360,27 +252,19 @@ def run_once(file_path: Optional[str] = None) -> dict:
|
|
| 360 |
attach = _df_to_excel_bytes(final)
|
| 361 |
ok, info = _send_email_via_resend(subject, html, attachment_bytes=attach, attachment_name=out_name)
|
| 362 |
|
| 363 |
-
return {
|
| 364 |
-
|
| 365 |
-
"msg": "邮件发送成功" if ok else f"邮件发送失败:{info}",
|
| 366 |
-
"input": file_path,
|
| 367 |
-
"output": out_path,
|
| 368 |
-
"rows": len(final),
|
| 369 |
-
}
|
| 370 |
except Exception as e:
|
| 371 |
import traceback
|
| 372 |
return {"ok": False, "msg": f"处理文件时发生严重错误: {e}", "traceback": traceback.format_exc()}
|
| 373 |
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
result = run_once(
|
| 377 |
-
print(json.dumps(result, ensure_ascii=False
|
| 378 |
-
|
| 379 |
if not result.get("ok"):
|
| 380 |
sys.exit(1)
|
| 381 |
|
| 382 |
-
|
| 383 |
if __name__ == "__main__":
|
| 384 |
-
|
| 385 |
-
arg_file = sys.argv[1] if len(sys.argv) > 1 else None
|
| 386 |
-
main(arg_file)
|
|
|
|
| 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
|
|
|
|
| 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 |
"主数量": ["主数量", "数量", "请购数量"],
|
|
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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).fillna('')
|
| 96 |
contains_e = df["物料名称"].str.contains("鹅")
|
|
|
|
| 99 |
contains_huazhu_special = df["物料名称"].str.contains("华住专用")
|
| 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 |
if "计划到货日期" in grouped.columns:
|
| 142 |
grouped = grouped.drop(columns=["计划到货日期"])
|
| 143 |
|
| 144 |
+
final_cols = [col for col in EMAIL_COLS_DEFAULT if col in grouped.columns]
|
| 145 |
+
final = grouped[final_cols]
|
| 146 |
+
|
|
|
|
| 147 |
date_cols_to_format = ["请购日期", "需求日期", "到货日期", "入库日期"]
|
| 148 |
for col in date_cols_to_format:
|
| 149 |
if col in final.columns:
|
|
|
|
| 150 |
final[col] = pd.to_datetime(final[col], errors='coerce').dt.strftime('%Y/%-m/%-d')
|
| 151 |
|
| 152 |
return final
|
| 153 |
|
|
|
|
| 154 |
def _calc_progress_row(row: pd.Series) -> str:
|
|
|
|
|
|
|
|
|
|
| 155 |
today = pd.Timestamp(_today())
|
| 156 |
main_qty = float(row.get("主数量", 0) or 0)
|
| 157 |
arr_qty = float(row.get("到货主数量", 0) or 0)
|
|
|
|
| 159 |
arrival_date = row.get("到货日期", pd.NaT)
|
| 160 |
plan_arrival = row.get("计划到货日期", pd.NaT)
|
| 161 |
|
| 162 |
+
# --- 逻辑修改:调整“完全到货”的判断标准 ---
|
| 163 |
+
# 当到货数量达到主数量的97%时,也视为完全到货
|
| 164 |
+
if main_qty > 0 and arr_qty >= main_qty * 0.97:
|
| 165 |
return "完全到货"
|
| 166 |
|
| 167 |
parts: List[str] = []
|
|
|
|
| 169 |
|
| 170 |
if arr_qty > 0:
|
| 171 |
parts.append(f"部分到货 缺货{shortage:g}米")
|
| 172 |
+
elif pd.isna(arrival_date) or arr_qty == 0:
|
| 173 |
+
parts.append("未到货")
|
|
|
|
| 174 |
|
| 175 |
if (pd.isna(arrival_date) or arr_qty < main_qty) and pd.notna(demand_date):
|
| 176 |
overdue_days = (today - demand_date).days
|
|
|
|
| 189 |
|
| 190 |
return ";".join(parts)
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
def _find_latest_input(input_dir: str) -> Optional[str]:
|
| 193 |
files = []
|
| 194 |
for pat in ("*.xlsx", "*.xls"):
|
|
|
|
| 198 |
files.sort(key=os.path.getmtime, reverse=True)
|
| 199 |
return files[0]
|
| 200 |
|
|
|
|
| 201 |
def _df_to_excel_bytes(df: pd.DataFrame) -> bytes:
|
| 202 |
bio = BytesIO()
|
| 203 |
df.to_excel(bio, index=False)
|
| 204 |
bio.seek(0)
|
| 205 |
return bio.read()
|
| 206 |
|
|
|
|
| 207 |
def _build_html_body(df: pd.DataFrame, title: str) -> str:
|
| 208 |
+
table_html = df.fillna('').to_html(index=False, escape=False, na_rep="")
|
| 209 |
+
html = f"""<html><head><meta charset="utf-8" /><style>
|
| 210 |
+
table {{ border-collapse: collapse; font-size: 13px; }}
|
| 211 |
+
table, th, td {{ border: 1px solid #ccc; padding: 6px; }}
|
| 212 |
+
th {{ background:#f6f6f6; }}
|
| 213 |
+
</style></head><body><h3>{title}</h3>{table_html}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
<p style="color:#666;">备注:此邮件由自动化系统生成。</p>
|
| 215 |
+
</body></html>"""
|
|
|
|
| 216 |
return html
|
| 217 |
|
| 218 |
+
def _send_email_via_resend(subject: str, html_body: str, attachment_bytes: Optional[bytes], attachment_name: str) -> Tuple[bool, str]:
|
|
|
|
|
|
|
|
|
|
| 219 |
if not (RESEND_API_KEY and FROM_EMAIL and TO_EMAIL):
|
| 220 |
return False, "缺少 Resend 配置(RESEND_API_KEY / FROM_EMAIL / TO_EMAIL)"
|
| 221 |
+
|
| 222 |
url = "https://api.resend.com/emails"
|
| 223 |
+
headers = {"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json"}
|
| 224 |
+
payload = {"from": FROM_EMAIL, "to": [TO_EMAIL], "subject": subject, "html": html_body}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
if attachment_bytes is not None:
|
| 226 |
+
payload["attachments"] = [{"filename": attachment_name, "content": base64.b64encode(attachment_bytes).decode("utf-8")}]
|
| 227 |
+
|
| 228 |
+
try:
|
| 229 |
+
resp = requests.post(url, headers=headers, data=json.dumps(payload), timeout=20)
|
| 230 |
+
resp.raise_for_status()
|
|
|
|
|
|
|
| 231 |
return True, resp.text
|
| 232 |
+
except requests.exceptions.RequestException as e:
|
| 233 |
+
return False, f"邮件发送请求失败: {e}"
|
| 234 |
|
| 235 |
# ====== 主流程 ======
|
| 236 |
def run_once(file_path: Optional[str] = None) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
try:
|
| 238 |
if file_path is None:
|
| 239 |
file_path = _find_latest_input(INPUT_DIR)
|
|
|
|
| 242 |
|
| 243 |
raw = read_system_export(file_path)
|
| 244 |
final = aggregate_for_email(raw)
|
| 245 |
+
|
| 246 |
out_name = f"邮件发送的格式_{datetime.now().strftime('%Y%m%d')}.xlsx"
|
| 247 |
out_path = os.path.join(OUTPUT_DIR, out_name)
|
|
|
|
| 248 |
final.to_excel(out_path, index=False)
|
| 249 |
|
| 250 |
subject = f"采购执行表自动推送 {datetime.now().date()}"
|
|
|
|
| 252 |
attach = _df_to_excel_bytes(final)
|
| 253 |
ok, info = _send_email_via_resend(subject, html, attachment_bytes=attach, attachment_name=out_name)
|
| 254 |
|
| 255 |
+
return {"ok": ok, "msg": "邮件发送成功" if ok else f"邮件发送失败:{info}", "input": file_path, "output": out_path, "rows": len(final)}
|
| 256 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
except Exception as e:
|
| 258 |
import traceback
|
| 259 |
return {"ok": False, "msg": f"处理文件时发生严重错误: {e}", "traceback": traceback.format_exc()}
|
| 260 |
|
| 261 |
+
def main():
|
| 262 |
+
arg_file = sys.argv[1] if len(sys.argv) > 1 else None
|
| 263 |
+
result = run_once(arg_file)
|
| 264 |
+
print(json.dumps(result, ensure_ascii=False))
|
| 265 |
+
|
| 266 |
if not result.get("ok"):
|
| 267 |
sys.exit(1)
|
| 268 |
|
|
|
|
| 269 |
if __name__ == "__main__":
|
| 270 |
+
main()
|
|
|
|
|
|