feat: add outfit excel service

This commit is contained in:
2026-06-18 17:35:00 +08:00
parent 382797cfea
commit 10238d3da3
3 changed files with 253 additions and 1 deletions
+139
View File
@@ -0,0 +1,139 @@
import logging
from pathlib import Path
from typing import List
from openpyxl import load_workbook
from core.models import OutfitResult, OutfitTask
logger = logging.getLogger(__name__)
STATUS_PENDING = "待处理"
STATUS_RUNNING = "生成中"
STATUS_DONE = "完成"
STATUS_FAILED = "失败"
STATUS_SKIPPED = "跳过"
COL_TITLE = 1
COL_PRODUCT_ID = 2
COL_GARMENT_PATH = 3
COL_OUTPUT_PATH = 4
COL_STATUS = 5
COL_ERROR = 6
class ExcelInUseError(OSError):
"""Raised when an Excel file cannot be opened for writing."""
def _cell_text(value):
"""Convert an Excel cell value to text without stripping user content."""
if value is None:
return ""
return str(value)
def _is_empty(value):
"""Return True only for genuinely empty cells.
Whitespace is preserved as user data, matching docs/11's "do not clean
fields" rule.
"""
return value is None or value == ""
def check_excel_writable(excel_path):
"""Return True if *excel_path* can be opened for writing.
This detects the common Windows case where Office keeps the workbook locked.
The actual save still happens through openpyxl and may raise OSError for
other filesystem errors.
"""
path = Path(excel_path)
try:
with open(str(path), "a+b"):
pass
return True
except OSError:
return False
def ensure_excel_writable(excel_path):
"""Raise ExcelInUseError if *excel_path* is not writable."""
if not check_excel_writable(excel_path):
raise ExcelInUseError("Excel 文件无法写入,请关闭后重试: {}".format(excel_path))
def load_outfit_tasks(excel_path, retry_failed=False):
"""Load processable outfit tasks from the first worksheet.
Column mapping follows docs/11-ai-outfit.md:
A title, B product id, C garment path, D output path, E status, F error.
Rows start at 2. Completed rows are always skipped. Failed rows are included
only when *retry_failed* is True. Rows missing title/product/path are skipped
silently and are not written back.
"""
path = Path(excel_path)
workbook = load_workbook(str(path))
try:
sheet = workbook.worksheets[0]
tasks = []
for row_index in range(2, sheet.max_row + 1):
title = sheet.cell(row_index, COL_TITLE).value
product_id = sheet.cell(row_index, COL_PRODUCT_ID).value
garment_path = sheet.cell(row_index, COL_GARMENT_PATH).value
if _is_empty(title) or _is_empty(product_id) or _is_empty(garment_path):
logger.debug("Skipped incomplete outfit row %s in %s", row_index, path)
continue
raw_status = sheet.cell(row_index, COL_STATUS).value
status = _cell_text(raw_status) or STATUS_PENDING
if status == STATUS_DONE:
continue
if status == STATUS_FAILED and not retry_failed:
continue
tasks.append(
OutfitTask(
row_index=row_index,
title=_cell_text(title),
product_id=_cell_text(product_id),
garment_path=_cell_text(garment_path),
status=status,
)
)
logger.info("Loaded %d outfit task(s) from %s", len(tasks), path)
return tasks
finally:
workbook.close()
def write_outfit_result(excel_path, result):
"""Write one outfit result to columns D/E/F and save immediately."""
if not isinstance(result, OutfitResult):
raise TypeError("result must be OutfitResult")
path = Path(excel_path)
workbook = load_workbook(str(path))
try:
sheet = workbook.worksheets[0]
row_index = result.task.row_index
if result.success:
sheet.cell(row_index, COL_OUTPUT_PATH).value = result.output_path
sheet.cell(row_index, COL_STATUS).value = STATUS_DONE
sheet.cell(row_index, COL_ERROR).value = ""
logger.info("Outfit row %s completed: %s", row_index, result.output_path)
else:
sheet.cell(row_index, COL_STATUS).value = STATUS_FAILED
sheet.cell(row_index, COL_ERROR).value = result.error
logger.warning("Outfit row %s failed: %s", row_index, result.error)
workbook.save(str(path))
finally:
workbook.close()