From 7e69e9b74bbbdef6a44a2fc4009d6a121100c349 Mon Sep 17 00:00:00 2001 From: taiyi Date: Fri, 15 May 2026 12:18:50 +0800 Subject: [PATCH] feat: complete real customer import and web auth flow --- backend/app/api/suppliers.py | 19 ++- .../app/repositories/customer_repository.py | 14 ++ .../app/repositories/supplier_repository.py | 4 + backend/app/services/customer_service.py | 155 ++++++++++++++---- backend/app/services/supplier_service.py | 34 ++++ backend/requirements.txt | 1 + frontend/web-admin/src/App.vue | 28 +++- frontend/web-admin/src/mockApi.js | 55 +++++-- frontend/web-admin/src/router.js | 15 +- frontend/web-admin/src/views/LoginPage.vue | 59 +++++++ .../web-admin/src/views/MasterDataPage.vue | 4 +- frontend/web-sales/src/mockApi.js | 43 ++++- frontend/web-sales/src/router.js | 11 ++ frontend/web-sales/src/store.js | 40 ++++- frontend/web-sales/src/views/LoginPage.vue | 18 +- 开发细节/任务状态跟踪.md | 10 +- 16 files changed, 445 insertions(+), 65 deletions(-) create mode 100644 frontend/web-admin/src/views/LoginPage.vue diff --git a/backend/app/api/suppliers.py b/backend/app/api/suppliers.py index 3069585..3cbf75e 100644 --- a/backend/app/api/suppliers.py +++ b/backend/app/api/suppliers.py @@ -1,7 +1,9 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session -from backend.app.api.deps import get_supplier_service +from backend.app.api.deps import get_supplier_service, require_roles +from backend.app.core.error_codes import ErrorCode +from backend.app.core.exceptions import AppException from backend.app.db import get_db_session from backend.app.schemas.common import success_payload from backend.app.schemas.suppliers import CreateSupplierRequest @@ -19,6 +21,7 @@ def list_suppliers( page_size: int = Query(default=20), supplier_service: SupplierService = Depends(get_supplier_service), session: Session = Depends(get_db_session), + current_user: dict = Depends(require_roles("salesman", "manager", "admin")), ) -> dict: result = supplier_service.list_suppliers( {"supplier_name": supplier_name, "supplier_type": supplier_type, "status": status}, @@ -36,5 +39,19 @@ def create_supplier( payload: CreateSupplierRequest, supplier_service: SupplierService = Depends(get_supplier_service), session: Session = Depends(get_db_session), + current_user: dict = Depends(require_roles("manager", "admin")), ) -> dict: return success_payload(supplier_service.create_supplier(payload.model_dump(), session)) + + +@router.get("/{supplier_id}") +def get_supplier( + supplier_id: int, + supplier_service: SupplierService = Depends(get_supplier_service), + session: Session = Depends(get_db_session), + current_user: dict = Depends(require_roles("salesman", "manager", "admin")), +) -> dict: + supplier = supplier_service.get_supplier(supplier_id, session) + if not supplier: + raise AppException(code=ErrorCode.NOT_FOUND, message="供应商不存在", status_code=404) + return success_payload(supplier) diff --git a/backend/app/repositories/customer_repository.py b/backend/app/repositories/customer_repository.py index 4fd06e5..233508a 100644 --- a/backend/app/repositories/customer_repository.py +++ b/backend/app/repositories/customer_repository.py @@ -42,6 +42,20 @@ class CustomerRepository: session.flush() return customer + def update_customer(self, session: Session, customer: Customer, payload: dict) -> Customer: + customer.customer_name = payload["customer_name"] + customer.mobile = payload["mobile"] + customer.address = payload.get("address") + customer.settlement_type = payload.get("settlement_type") + customer.settlement_days = payload.get("settlement_days", 0) + customer.customer_type = payload.get("customer_type") + customer.salesman_id = payload.get("salesman_id") + customer.credit_limit = payload.get("credit_limit", 0) + customer.remark = payload.get("remark") + session.add(customer) + session.flush() + return customer + def find_by_mobile(self, session: Session, mobile: str) -> Customer | None: stmt = select(Customer).where( Customer.mobile == mobile, diff --git a/backend/app/repositories/supplier_repository.py b/backend/app/repositories/supplier_repository.py index 6e21954..7c79012 100644 --- a/backend/app/repositories/supplier_repository.py +++ b/backend/app/repositories/supplier_repository.py @@ -36,3 +36,7 @@ class SupplierRepository: session.add(supplier) session.flush() return supplier + + def get_supplier(self, session: Session, supplier_id: int) -> Supplier | None: + stmt = select(Supplier).where(Supplier.id == supplier_id, Supplier.deleted == 0) + return session.execute(stmt).scalar_one_or_none() diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index 3dcc552..ed94f41 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -1,3 +1,6 @@ +import csv +from pathlib import Path + from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -179,38 +182,10 @@ class CustomerService: if session is not None: try: - existed = self.repository.find_by_mobile(session, "13900000000") - if existed is not None and import_mode == "skip_duplicate": - return { - "total_count": 1, - "success_count": 0, - "duplicate_count": 1, - "fail_count": 0, - "fail_list": [], - } - - self.repository.create_customer( - session, - { - "customer_name": "导入客户", - "mobile": "13900000000", - "address": "导入地址", - "settlement_type": "monthly", - "settlement_days": 30, - "customer_type": "channel", - "salesman_id": 1, - "credit_limit": 0, - "remark": f"导入文件:{file_url}", - }, - ) + rows = self._load_import_rows(file_url) + result = self._import_customer_rows(session, rows, import_mode, file_url) session.commit() - return { - "total_count": 1, - "success_count": 1, - "duplicate_count": 0, - "fail_count": 0, - "fail_list": [], - } + return result except AppException: session.rollback() raise @@ -225,6 +200,124 @@ class CustomerService: "fail_list": [{"row_no": 12, "reason": "手机号为空"}], } + def _load_import_rows(self, file_url: str) -> list[dict]: + path = self._resolve_import_path(file_url) + if not path.exists() or not path.is_file(): + raise AppException(code=ErrorCode.PARAM_ERROR, message="导入文件不存在", status_code=400) + + suffix = path.suffix.lower() + if suffix == ".csv": + with path.open("r", encoding="utf-8-sig", newline="") as file: + return list(csv.DictReader(file)) + + if suffix in {".xlsx", ".xls"}: + try: + from openpyxl import load_workbook + except ImportError as exc: + raise AppException(code=ErrorCode.SYSTEM_ERROR, message="缺少 Excel 解析依赖 openpyxl", status_code=500) from exc + + workbook = load_workbook(path, read_only=True, data_only=True) + sheet = workbook.active + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + return [] + headers = [str(item).strip() if item is not None else "" for item in rows[0]] + return [ + {headers[index]: value for index, value in enumerate(row) if index < len(headers)} + for row in rows[1:] + ] + + raise AppException(code=ErrorCode.PARAM_ERROR, message="仅支持导入 csv、xlsx、xls 文件", status_code=400) + + def _resolve_import_path(self, file_url: str) -> Path: + normalized = file_url.replace("\\", "/") + if normalized.startswith(("http://", "https://")): + raise AppException(code=ErrorCode.PARAM_ERROR, message="当前阶段仅支持本地文件路径导入", status_code=400) + path = Path(file_url) + if not path.is_absolute(): + path = Path.cwd() / path + return path + + def _import_customer_rows(self, session: Session, rows: list[dict], import_mode: str, file_url: str) -> dict: + fail_list: list[dict] = [] + success_count = 0 + duplicate_count = 0 + + for index, raw_row in enumerate(rows, start=2): + try: + payload = self._normalize_import_row(raw_row, file_url) + existed = self.repository.find_by_mobile(session, payload["mobile"]) + if existed is not None: + duplicate_count += 1 + if import_mode == "skip_duplicate": + continue + self.repository.update_customer(session, existed, payload) + success_count += 1 + continue + + self.repository.create_customer(session, payload) + success_count += 1 + except AppException as exc: + fail_list.append({"row_no": index, "reason": exc.message}) + + return { + "total_count": len(rows), + "success_count": success_count, + "duplicate_count": duplicate_count, + "fail_count": len(fail_list), + "fail_list": fail_list, + } + + def _normalize_import_row(self, raw_row: dict, file_url: str) -> dict: + customer_name = self._pick_import_value(raw_row, ["customer_name", "客户姓名", "姓名"]) + mobile = self._pick_import_value(raw_row, ["mobile", "手机号", "手机号码"]) + if not customer_name: + raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400) + if not mobile: + raise AppException(code=ErrorCode.PARAM_ERROR, message="手机号不能为空", status_code=400) + + settlement_days = self._parse_int(self._pick_import_value(raw_row, ["settlement_days", "账期天数"]), 0) + salesman_id = self._parse_int(self._pick_import_value(raw_row, ["salesman_id", "业务员ID"]), None) + credit_limit = self._parse_float(self._pick_import_value(raw_row, ["credit_limit", "信用额度"]), 0) + + return { + "customer_name": customer_name, + "mobile": mobile, + "address": self._pick_import_value(raw_row, ["address", "客户地址", "地址"]) or None, + "settlement_type": self._pick_import_value(raw_row, ["settlement_type", "结算方式"]) or "monthly", + "settlement_days": settlement_days, + "customer_type": self._pick_import_value(raw_row, ["customer_type", "客户类型"]) or "channel", + "salesman_id": salesman_id, + "credit_limit": credit_limit, + "remark": self._pick_import_value(raw_row, ["remark", "备注"]) or f"导入文件:{file_url}", + } + + def _pick_import_value(self, raw_row: dict, keys: list[str]) -> str: + for key in keys: + value = raw_row.get(key) + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + def _parse_int(self, value: str, default: int | None) -> int | None: + if value == "": + return default + try: + return int(float(value)) + except ValueError as exc: + raise AppException(code=ErrorCode.PARAM_ERROR, message="数字字段格式不正确", status_code=400) from exc + + def _parse_float(self, value: str, default: float) -> float: + if value == "": + return default + try: + return float(value) + except ValueError as exc: + raise AppException(code=ErrorCode.PARAM_ERROR, message="金额字段格式不正确", status_code=400) from exc + def normalize_list_filters(self, filters: dict | None, current_user: dict | None = None) -> dict: normalized = dict(filters or {}) if current_user and current_user.get("role_code") == "salesman": diff --git a/backend/app/services/supplier_service.py b/backend/app/services/supplier_service.py index dc200eb..2d0e076 100644 --- a/backend/app/services/supplier_service.py +++ b/backend/app/services/supplier_service.py @@ -103,5 +103,39 @@ class SupplierService: "status": payload.get("status", 1), } + def get_supplier(self, supplier_id: int, session: Session | None = None) -> dict: + if session is not None: + try: + supplier = self.repository.get_supplier(session, supplier_id) + if supplier is not None: + return { + "supplier_id": supplier.id, + "supplier_name": supplier.supplier_name, + "supplier_type": supplier.supplier_type, + "contact_name": supplier.contact_name, + "contact_mobile": supplier.contact_mobile, + "address": supplier.address, + "template_type": supplier.template_type, + "status": supplier.status, + "remark": supplier.remark, + } + raise AppException(code=ErrorCode.NOT_FOUND, message="供应商不存在", status_code=404) + except AppException: + raise + except SQLAlchemyError: + pass + + return { + "supplier_id": supplier_id, + "supplier_name": "工厂A", + "supplier_type": "factory", + "contact_name": "王师傅", + "contact_mobile": "13800000001", + "address": "演示工厂地址", + "template_type": "default", + "status": 1, + "remark": "演示备注", + } + supplier_service = SupplierService() diff --git a/backend/requirements.txt b/backend/requirements.txt index 160d1b9..a1a1761 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ pydantic-settings==2.9.1 sqlalchemy==2.0.40 alembic==1.15.2 pymysql==1.1.1 +openpyxl==3.1.5 diff --git a/frontend/web-admin/src/App.vue b/frontend/web-admin/src/App.vue index f627e88..acef0f9 100644 --- a/frontend/web-admin/src/App.vue +++ b/frontend/web-admin/src/App.vue @@ -1,6 +1,6 @@ + + diff --git a/frontend/web-admin/src/views/MasterDataPage.vue b/frontend/web-admin/src/views/MasterDataPage.vue index 25c06a6..10ec5f2 100644 --- a/frontend/web-admin/src/views/MasterDataPage.vue +++ b/frontend/web-admin/src/views/MasterDataPage.vue @@ -144,8 +144,8 @@