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 @@