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()
+1 -1
View File
@@ -1047,7 +1047,7 @@
- [x] 前置依赖:`requirements.txt` 增加 Python 3.7 兼容的 `requests` / `urllib3` / `openpyxl` 锁定版本,并同步 `docs/03-technical-stack.md` - [x] 前置依赖:`requirements.txt` 增加 Python 3.7 兼容的 `requests` / `urllib3` / `openpyxl` 锁定版本,并同步 `docs/03-technical-stack.md`
- [x] `core/models.py` 新增 `OutfitTask` / `OutfitResult`(纯 dataclass,Python 3.7 兼容,不依赖 PySide6) - [x] `core/models.py` 新增 `OutfitTask` / `OutfitResult`(纯 dataclass,Python 3.7 兼容,不依赖 PySide6)
- [ ] `services/excel_service.py`:读行 → `List[OutfitTask]`、写回 D/E/F、占用检测、跳过「完成」/按设置重试「失败」/空字段安全跳过、每行即存 - [x] `services/excel_service.py`:读行 → `List[OutfitTask]`、写回 D/E/F、占用检测、跳过「完成」/按设置重试「失败」/空字段安全跳过、每行即存
- [ ] `services/ai_image_service.py`:移植旧项目 `ImageApiClient`(多模型、多请求格式 `chat/gemini/images/images_edits`、传图 data-url、递归取图、URL 归一化、字段校验);注意 PEP585 类型注解改 Python 3.7 写法 - [ ] `services/ai_image_service.py`:移植旧项目 `ImageApiClient`(多模型、多请求格式 `chat/gemini/images/images_edits`、传图 data-url、递归取图、URL 归一化、字段校验);注意 PEP585 类型注解改 Python 3.7 写法
- [ ] `core/ai_outfit.py`:单行生成纯逻辑编排(提示词渲染 + 调用 + 保存 JPG + 产出 `OutfitResult`) - [ ] `core/ai_outfit.py`:单行生成纯逻辑编排(提示词渲染 + 调用 + 保存 JPG + 产出 `OutfitResult`)
- [ ] 单测:Excel 读写、提示词渲染、取图、命名去重(API 用 mock);可在 Python 3.7 运行、不依赖 GUI - [ ] 单测:Excel 读写、提示词渲染、取图、命名去重(API 用 mock);可在 Python 3.7 运行、不依赖 GUI
+113
View File
@@ -0,0 +1,113 @@
"""Tests for AI outfit Excel service.
Pure service tests: no GUI dependency.
"""
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from openpyxl import Workbook, load_workbook
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
class TestOutfitExcelService(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.excel_path = self.tmp / "outfit.xlsx"
self._write_workbook()
def tearDown(self):
shutil.rmtree(str(self.tmp), ignore_errors=True)
def _write_workbook(self):
wb = Workbook()
ws = wb.active
ws.append(["标题", "货号", "衣服图", "结果图", "状态", "原因"])
ws.append(["T恤", "TY001", r"D:\img\ty001.png", "", "", ""])
ws.append(["已完成", "TY002", r"D:\img\ty002.png", r"D:\out\ty002.jpg", "完成", ""])
ws.append(["失败行", "TY003", r"D:\img\ty003.png", "", "失败", "旧错误"])
ws.append(["缺路径", "TY004", "", "", "", ""])
ws.append([" 保留空格 ", 1005, r"D:\img\ty005.png", "", "", ""])
wb.save(str(self.excel_path))
wb.close()
def _read_row(self, row):
wb = load_workbook(str(self.excel_path))
try:
ws = wb.active
return (
ws.cell(row, 4).value,
ws.cell(row, 5).value,
ws.cell(row, 6).value,
)
finally:
wb.close()
def test_load_tasks_skips_done_failed_and_incomplete_by_default(self):
from services.excel_service import load_outfit_tasks
tasks = load_outfit_tasks(self.excel_path)
self.assertEqual([task.row_index for task in tasks], [2, 6])
self.assertEqual(tasks[0].title, "T恤")
self.assertEqual(tasks[0].product_id, "TY001")
self.assertEqual(tasks[0].garment_path, r"D:\img\ty001.png")
def test_load_tasks_includes_failed_when_retry_enabled(self):
from services.excel_service import load_outfit_tasks
tasks = load_outfit_tasks(self.excel_path, retry_failed=True)
self.assertEqual([task.row_index for task in tasks], [2, 4, 6])
self.assertEqual(tasks[1].status, "失败")
def test_load_tasks_preserves_user_cell_text(self):
from services.excel_service import load_outfit_tasks
tasks = load_outfit_tasks(self.excel_path)
task = tasks[-1]
self.assertEqual(task.title, " 保留空格 ")
self.assertEqual(task.product_id, "1005")
def test_write_success_result_updates_d_e_f_and_saves(self):
from core.models import OutfitResult, OutfitTask
from services.excel_service import write_outfit_result
task = OutfitTask(row_index=2, title="T恤", product_id="TY001", garment_path="g.png")
result = OutfitResult(task=task, success=True, output_path=r"D:\out\TY001.jpg")
write_outfit_result(self.excel_path, result)
self.assertEqual(self._read_row(2), (r"D:\out\TY001.jpg", "完成", None))
def test_write_failure_result_updates_e_f_and_saves(self):
from core.models import OutfitResult, OutfitTask
from services.excel_service import write_outfit_result
task = OutfitTask(row_index=2, title="T恤", product_id="TY001", garment_path="g.png")
result = OutfitResult(task=task, success=False, error="衣服图打不开")
write_outfit_result(self.excel_path, result)
self.assertEqual(self._read_row(2), (None, "失败", "衣服图打不开"))
def test_check_excel_writable_true_for_existing_workbook(self):
from services.excel_service import check_excel_writable
self.assertTrue(check_excel_writable(self.excel_path))
def test_ensure_excel_writable_raises_when_probe_fails(self):
from services.excel_service import ExcelInUseError, ensure_excel_writable
with patch("builtins.open", side_effect=OSError("locked")):
with self.assertRaises(ExcelInUseError):
ensure_excel_writable(self.excel_path)
if __name__ == "__main__":
unittest.main()