feat: add outfit excel service
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user