Initial commit

This commit is contained in:
汤学会
2026-06-12 16:00:56 +08:00
commit c927886888
337 changed files with 160396 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+38
View File
@@ -0,0 +1,38 @@
from fastapi import APIRouter
from app.api.routes import (
auth,
dashboard,
document_archives,
equipment,
finance,
inventory,
master_data,
planning,
production,
purchase,
quality,
returns,
sales,
system,
system_extension,
system_permissions,
)
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(document_archives.router, prefix="/document-archives", tags=["document-archives"])
api_router.include_router(system.router, prefix="/system", tags=["system"])
api_router.include_router(system_extension.router, prefix="/system-extension", tags=["system-extension"])
api_router.include_router(system_permissions.router, prefix="/system-permissions", tags=["system-permissions"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
api_router.include_router(master_data.router, prefix="/master-data", tags=["master-data"])
api_router.include_router(sales.router, prefix="/sales", tags=["orders-customers"])
api_router.include_router(planning.router, prefix="/planning", tags=["planning"])
api_router.include_router(purchase.router, prefix="/purchase", tags=["purchase"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(production.router, prefix="/production", tags=["production"])
api_router.include_router(equipment.router, prefix="/equipment", tags=["equipment"])
api_router.include_router(quality.router, prefix="/quality", tags=["quality"])
api_router.include_router(returns.router, prefix="/returns", tags=["returns"])
api_router.include_router(finance.router, prefix="/finance", tags=["finance"])
+1
View File
@@ -0,0 +1 @@
+69
View File
@@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.org import User
from app.schemas.database import (
AuthChangePasswordRequest,
AuthLoginRequest,
AuthResetPasswordRequest,
AuthSession,
LoginUser,
)
from app.services.auth import (
build_login_user,
get_current_auth_context,
hash_password,
login_with_password,
require_system_admin,
verify_password,
)
router = APIRouter()
@router.post("/login", response_model=AuthSession)
def login(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthSession:
return login_with_password(db, payload.username, payload.password)
@router.get("/me", response_model=LoginUser)
def profile(context=Depends(get_current_auth_context)) -> LoginUser:
return build_login_user(context)
@router.post("/password/change")
def change_password(
payload: AuthChangePasswordRequest,
context=Depends(get_current_auth_context),
db: Session = Depends(get_db),
) -> dict[str, str]:
user = db.get(User, context.user.id)
if not user:
raise HTTPException(status_code=404, detail="当前用户不存在")
if not verify_password(payload.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="原密码不正确")
if verify_password(payload.new_password, user.password_hash):
raise HTTPException(status_code=400, detail="新密码不能与原密码相同")
user.password_hash = hash_password(payload.new_password)
db.add(user)
db.commit()
return {"message": "密码已修改"}
@router.post("/password/reset")
def reset_password(
payload: AuthResetPasswordRequest,
context=Depends(require_system_admin),
db: Session = Depends(get_db),
) -> dict[str, str]:
user = db.scalar(select(User).where(User.username == payload.username))
if not user:
raise HTTPException(status_code=404, detail="账号不存在")
user.password_hash = hash_password(payload.new_password)
db.add(user)
db.commit()
return {"message": f"账号 {payload.username} 密码已重置"}
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import quote
from zipfile import ZipFile
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.document_archives import DocumentArchiveBatchDownloadRequest, DocumentArchiveGenerateResult
from app.services.auth import AuthContext, require_authenticated_user
from app.services.document_archives import (
ARCHIVE_STATUS_READY,
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
DOCUMENT_TYPE_PURCHASE_ORDER,
DOCUMENT_TYPE_PURCHASE_RECEIPT,
DOCUMENT_TYPE_QUALITY_INSPECTION,
DOCUMENT_TYPE_SALES_ORDER,
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
FILE_FORMAT_PDF,
build_archive_zip,
default_archive_root,
generate_document_archive,
get_latest_archive,
safe_filename,
)
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
DOCUMENT_TYPE_KEY_MAP = {
"sales-order": DOCUMENT_TYPE_SALES_ORDER,
"purchase-order": DOCUMENT_TYPE_PURCHASE_ORDER,
"purchase-receipt": DOCUMENT_TYPE_PURCHASE_RECEIPT,
"quality-inspection": DOCUMENT_TYPE_QUALITY_INSPECTION,
"production-material-out": DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
"production-inbound-settlement": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
"warehouse-operation": DOCUMENT_TYPE_WAREHOUSE_OPERATION,
DOCUMENT_TYPE_SALES_ORDER: DOCUMENT_TYPE_SALES_ORDER,
DOCUMENT_TYPE_PURCHASE_ORDER: DOCUMENT_TYPE_PURCHASE_ORDER,
DOCUMENT_TYPE_PURCHASE_RECEIPT: DOCUMENT_TYPE_PURCHASE_RECEIPT,
DOCUMENT_TYPE_QUALITY_INSPECTION: DOCUMENT_TYPE_QUALITY_INSPECTION,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_WAREHOUSE_OPERATION: DOCUMENT_TYPE_WAREHOUSE_OPERATION,
}
def normalize_document_type_key(document_type_key: str) -> str:
document_type = DOCUMENT_TYPE_KEY_MAP.get(document_type_key)
if document_type is None:
raise HTTPException(
status_code=400,
detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单或仓库出入库单",
)
return document_type
def _content_disposition(filename: str, inline: bool) -> str:
disposition = "inline" if inline else "attachment"
ascii_filename = safe_filename(filename.encode("ascii", "ignore").decode("ascii"))
encoded_filename = quote(filename, safe="")
return f"{disposition}; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}"
def _archive_file_response(path: str | Path, filename: str, inline: bool) -> FileResponse:
file_path = Path(path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成")
return FileResponse(
file_path,
media_type="application/pdf",
filename=filename,
headers={"Content-Disposition": _content_disposition(filename, inline)},
)
def _extract_user_id(context: AuthContext | dict[str, Any] | Any) -> int | None:
if isinstance(context, dict):
value = context.get("user_id", context.get("uid", context.get("id")))
return int(value) if value is not None else None
user = getattr(context, "user", None)
value = getattr(user, "id", None)
if value is None:
value = getattr(context, "user_id", None)
return int(value) if value is not None else None
def _latest_ready_pdf_or_404(db: Session, document_type: str, business_id: int):
archive = get_latest_archive(db, document_type, business_id)
if archive is None or archive.status != ARCHIVE_STATUS_READY or archive.file_format != FILE_FORMAT_PDF:
raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成")
return archive
def _ensure_zip_has_downloadable_archives(zip_path: Path) -> None:
with ZipFile(zip_path) as archive_zip:
if not archive_zip.namelist():
raise HTTPException(status_code=404, detail="没有可下载的归档文件,请先生成PDF归档")
@router.post("/{document_type_key}/{business_id}/generate", response_model=DocumentArchiveGenerateResult)
def generate_archive(
document_type_key: str,
business_id: int,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> DocumentArchiveGenerateResult:
document_type = normalize_document_type_key(document_type_key)
return generate_document_archive(db, document_type, business_id, created_by=_extract_user_id(context))
@router.get("/{document_type_key}/{business_id}/latest/preview")
def preview_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse:
document_type = normalize_document_type_key(document_type_key)
archive = _latest_ready_pdf_or_404(db, document_type, business_id)
return _archive_file_response(archive.file_path, archive.file_name, inline=True)
@router.get("/{document_type_key}/{business_id}/latest/download")
def download_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse:
document_type = normalize_document_type_key(document_type_key)
archive = _latest_ready_pdf_or_404(db, document_type, business_id)
return _archive_file_response(archive.file_path, archive.file_name, inline=False)
@router.post("/batch-download")
def batch_download_archives(payload: DocumentArchiveBatchDownloadRequest, db: Session = Depends(get_db)) -> FileResponse:
document_type = normalize_document_type_key(payload.document_type)
batch_dir = Path(default_archive_root()) / "batch"
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
zip_filename = safe_filename(f"{document_type}_归档_{timestamp}") + ".zip"
zip_path = build_archive_zip(db, document_type, payload.business_ids, batch_dir / zip_filename)
_ensure_zip_has_downloadable_archives(zip_path)
return FileResponse(
zip_path,
media_type="application/zip",
filename=zip_filename,
headers={"Content-Disposition": _content_disposition(zip_filename, inline=False)},
)
+136
View File
@@ -0,0 +1,136 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.operations import Equipment, MaintenanceOrder, WorkCenter
from app.schemas.operations import (
EquipmentCreate,
EquipmentRead,
MaintenanceOrderCreate,
MaintenanceOrderRead,
)
from app.services.auth import require_authenticated_user
from app.services.operations import get_equipment_query, get_maintenance_orders_query, to_decimal
from app.services.system_permissions import ensure_employee_has_permission
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
def _code_token(value: str | None, fallback: str, max_length: int = 12) -> str:
token = "".join(ch for ch in str(value or "").upper() if ch.isalnum())
return (token or fallback)[-max_length:]
def _next_doc_no(db: Session, model, column_name: str, prefix: str) -> str:
column = getattr(model, column_name)
count = db.scalar(select(func.count(model.id)).where(column.like(f"{prefix}-%"))) or 0
return f"{prefix}-{count + 1:03d}"
def build_equipment_code(db: Session, work_center: WorkCenter, equipment_name: str | None) -> str:
date_part = datetime.now().strftime("%Y%m%d")
center_part = _code_token(work_center.center_code or work_center.center_name, f"WC{work_center.id:04d}", 10)
equipment_part = _code_token(equipment_name, "EQ", 10)
return _next_doc_no(db, Equipment, "equipment_code", f"EQ-NB-{date_part}-{center_part}-{equipment_part}")
def build_maintenance_order_no(db: Session, equipment: Equipment, maintenance_type: str | None) -> str:
date_part = datetime.now().strftime("%Y%m%d")
equipment_part = _code_token(equipment.equipment_code or equipment.equipment_name, f"EQ{equipment.id:04d}", 14)
type_part = _code_token(maintenance_type, "MTN", 8)
return _next_doc_no(db, MaintenanceOrder, "order_no", f"MTN-NB-{date_part}-{equipment_part}-{type_part}")
@router.get("/assets", response_model=list[EquipmentRead])
def list_equipment_assets(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[EquipmentRead]:
rows = db.execute(get_equipment_query(limit=limit)).mappings().all()
return [EquipmentRead.model_validate(dict(row)) for row in rows]
@router.post("/assets", response_model=EquipmentRead)
def create_equipment_asset(payload: EquipmentCreate, db: Session = Depends(get_db)) -> EquipmentRead:
work_center = db.get(WorkCenter, payload.work_center_id)
if not work_center:
raise HTTPException(status_code=404, detail="工作中心不存在")
equipment = Equipment(
equipment_code=build_equipment_code(db, work_center, payload.equipment_name),
equipment_name=payload.equipment_name,
category_name=payload.category_name,
work_center_id=payload.work_center_id,
manufacturer=payload.manufacturer,
model_no=payload.model_no,
install_date=payload.install_date,
rated_power_kw=to_decimal(payload.rated_power_kw, "0.01"),
occupied_area_sqm=to_decimal(payload.occupied_area_sqm, "0.01"),
maintenance_cycle_hours=to_decimal(payload.maintenance_cycle_hours, "0.01"),
last_maintenance_at=None,
next_maintenance_at=payload.next_maintenance_at,
status=payload.status,
remark=payload.remark,
)
db.add(equipment)
db.commit()
row = db.execute(get_equipment_query(limit=1).where(Equipment.id == equipment.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="设备创建后读取失败")
return EquipmentRead.model_validate(dict(row))
@router.get("/maintenance-orders", response_model=list[MaintenanceOrderRead])
def list_maintenance_orders(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[MaintenanceOrderRead]:
rows = db.execute(get_maintenance_orders_query(limit=limit)).mappings().all()
return [MaintenanceOrderRead.model_validate(dict(row)) for row in rows]
@router.post("/maintenance-orders", response_model=MaintenanceOrderRead)
def create_maintenance_order(payload: MaintenanceOrderCreate, db: Session = Depends(get_db)) -> MaintenanceOrderRead:
equipment = db.get(Equipment, payload.equipment_id)
if not equipment:
raise HTTPException(status_code=404, detail="设备不存在")
ensure_employee_has_permission(
db,
payload.owner_employee_id,
"MENU_EQUIPMENT_MANAGEMENT",
"负责人",
required=False,
)
order = MaintenanceOrder(
order_no=build_maintenance_order_no(db, equipment, payload.maintenance_type),
equipment_id=payload.equipment_id,
plan_id=None,
maintenance_type=payload.maintenance_type,
start_time=payload.start_time or datetime.now(),
end_time=payload.end_time,
downtime_minutes=to_decimal(payload.downtime_minutes, "0.01"),
owner_employee_id=payload.owner_employee_id,
result_summary=payload.result_summary,
cost_amount=to_decimal(payload.cost_amount, "0.01"),
status=payload.status,
)
db.add(order)
if payload.status == "COMPLETED" or payload.end_time:
finished_time = payload.end_time or datetime.now()
equipment.last_maintenance_at = finished_time
if to_decimal(equipment.maintenance_cycle_hours, "0.01") > 0:
equipment.next_maintenance_at = finished_time + timedelta(hours=float(equipment.maintenance_cycle_hours))
db.add(equipment)
db.commit()
row = db.execute(get_maintenance_orders_query(limit=1).where(MaintenanceOrder.id == order.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="保养工单创建后读取失败")
return MaintenanceOrderRead.model_validate(dict(row))
+178
View File
@@ -0,0 +1,178 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.operations import AccountingPeriod, OverheadEntry, StatementSnapshot
from app.schemas.operations import (
AccountingPeriodCreate,
AccountingPeriodRead,
CostAllocationGenerateResult,
CostAllocationRead,
OverheadEntryCreate,
OverheadEntryRead,
ProductMaterialCostBoardRead,
StatementSnapshotCreate,
StatementSnapshotRead,
)
from app.services.auth import AuthContext, require_authenticated_user
from app.services.operations import (
build_product_material_cost_board,
build_statement_summary,
generate_cost_allocations,
get_cost_allocation_rows,
get_overheads_query,
get_periods_query,
get_statement_snapshots_query,
to_decimal,
)
from app.services.sales_planning import code_token, next_doc_no
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
def build_statement_snapshot_no(db: Session, period: AccountingPeriod, statement_type: str, report_date: date) -> str:
date_part = report_date.strftime("%Y%m%d")
period_part = code_token(period.period_code, f"PER{period.id:04d}", 8)
type_part = code_token(statement_type, "STM", 8)
return next_doc_no(db, StatementSnapshot, "snapshot_no", f"FIN-NB-{date_part}-{period_part}-{type_part}")
@router.get("/product-material-cost-board", response_model=ProductMaterialCostBoardRead)
def get_product_material_cost_board(
date_from: date | None = Query(default=None),
date_to: date | None = Query(default=None),
db: Session = Depends(get_db),
) -> ProductMaterialCostBoardRead:
end_date = date_to or date.today()
start_date = date_from or end_date.replace(day=1)
return build_product_material_cost_board(db, date_from=start_date, date_to=end_date)
@router.get("/periods", response_model=list[AccountingPeriodRead])
def list_accounting_periods(
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
) -> list[AccountingPeriodRead]:
rows = db.execute(get_periods_query(limit=limit)).mappings().all()
return [AccountingPeriodRead.model_validate(dict(row)) for row in rows]
@router.post("/periods", response_model=AccountingPeriodRead)
def create_accounting_period(payload: AccountingPeriodCreate, db: Session = Depends(get_db)) -> AccountingPeriodRead:
period_code = f"{payload.year_no}{payload.month_no:02d}"
period = AccountingPeriod(
period_code=period_code,
year_no=payload.year_no,
month_no=payload.month_no,
start_date=payload.start_date,
end_date=payload.end_date,
close_status=payload.close_status,
closed_at=None,
)
db.add(period)
db.commit()
row = db.execute(get_periods_query(limit=1).where(AccountingPeriod.id == period.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="财务期间创建后读取失败")
return AccountingPeriodRead.model_validate(dict(row))
@router.get("/overheads", response_model=list[OverheadEntryRead])
def list_overhead_entries(
limit: int = Query(default=100, ge=1, le=300),
db: Session = Depends(get_db),
) -> list[OverheadEntryRead]:
rows = db.execute(get_overheads_query(limit=limit)).mappings().all()
return [OverheadEntryRead.model_validate(dict(row)) for row in rows]
@router.post("/overheads", response_model=OverheadEntryRead)
def create_overhead_entry(
payload: OverheadEntryCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> OverheadEntryRead:
period = db.get(AccountingPeriod, payload.period_id)
if not period:
raise HTTPException(status_code=404, detail="财务期间不存在")
entry = OverheadEntry(
period_id=payload.period_id,
overhead_type=payload.overhead_type,
dept_id=payload.dept_id,
amount=to_decimal(payload.amount, "0.01"),
allocation_basis=payload.allocation_basis,
description=payload.description,
created_by=context.user.id,
)
db.add(entry)
db.commit()
row = db.execute(get_overheads_query(limit=1).where(OverheadEntry.id == entry.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="制造费用录入后读取失败")
return OverheadEntryRead.model_validate(dict(row))
@router.get("/cost-allocations", response_model=list[CostAllocationRead])
def list_cost_allocations(
period_id: int | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[CostAllocationRead]:
return get_cost_allocation_rows(db, period_id=period_id, limit=limit)
@router.post("/generate-cost-allocations/{period_id}", response_model=CostAllocationGenerateResult)
def generate_period_cost_allocations(period_id: int, db: Session = Depends(get_db)) -> CostAllocationGenerateResult:
period = db.get(AccountingPeriod, period_id)
if not period:
raise HTTPException(status_code=404, detail="财务期间不存在")
rows = generate_cost_allocations(db, period_id)
db.commit()
return CostAllocationGenerateResult(
period_id=period.id,
period_code=period.period_code,
generated_count=len(rows),
rows=rows,
)
@router.get("/statements", response_model=list[StatementSnapshotRead])
def list_statement_snapshots(
limit: int = Query(default=100, ge=1, le=300),
db: Session = Depends(get_db),
) -> list[StatementSnapshotRead]:
rows = db.execute(get_statement_snapshots_query(limit=limit)).mappings().all()
return [StatementSnapshotRead.model_validate(dict(row)) for row in rows]
@router.post("/statements", response_model=StatementSnapshotRead)
def create_statement_snapshot(
payload: StatementSnapshotCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> StatementSnapshotRead:
period = db.get(AccountingPeriod, payload.period_id)
if not period:
raise HTTPException(status_code=404, detail="财务期间不存在")
summary_json = build_statement_summary(db, payload.period_id, payload.report_date)
snapshot = StatementSnapshot(
snapshot_no=build_statement_snapshot_no(db, period, payload.statement_type, payload.report_date),
period_id=payload.period_id,
statement_type=payload.statement_type,
report_date=payload.report_date,
summary_json=summary_json,
created_by=context.user.id,
)
db.add(snapshot)
db.commit()
row = db.execute(get_statement_snapshots_query(limit=1).where(StatementSnapshot.id == snapshot.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="财务报表快照创建后读取失败")
return StatementSnapshotRead.model_validate(dict(row))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.database import MaterialDemandRead, MrpGenerationResult
from app.services.auth import require_authenticated_user
from app.services.sales_planning import generate_mrp_for_order, get_mrp_demands_query
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
@router.get("/mrp-demands", response_model=list[MaterialDemandRead])
def list_mrp_demands(
sales_order_id: int | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
) -> list[MaterialDemandRead]:
stmt = get_mrp_demands_query(limit=limit, sales_order_id=sales_order_id)
rows = db.execute(stmt).mappings().all()
return [MaterialDemandRead.model_validate(dict(row)) for row in rows]
@router.post("/generate-mrp/{sales_order_id}", response_model=MrpGenerationResult)
def generate_mrp(sales_order_id: int, db: Session = Depends(get_db)) -> MrpGenerationResult:
sales_order, demand_rows = generate_mrp_for_order(db, sales_order_id)
return MrpGenerationResult(
sales_order_id=sales_order.id,
order_no=sales_order.order_no,
generated_count=len(demand_rows),
demand_rows=demand_rows,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.operations import PurchaseOrderItem, PurchaseReceiptItem, StockLot
from app.models.planning import MaterialDemand
from app.schemas.operations import QualityInspectionAction, QualityInspectionRead
from app.services.auth import AuthContext, require_authenticated_user
from app.services.document_archives import DOCUMENT_TYPE_QUALITY_INSPECTION, generate_document_archive
from app.services.operations import (
get_quality_inspections_query,
recalc_purchase_order_item_received_weight,
sync_purchase_order_status,
sync_purchase_receipt_status,
to_decimal,
upsert_stock_balance,
)
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
def _auth_context_user_id(context: AuthContext | object) -> int | None:
user = getattr(context, "user", None)
user_id = getattr(user, "id", None)
return int(user_id) if user_id is not None else None
def _read_quality_inspection(db: Session, receipt_item_id: int) -> QualityInspectionRead:
row = db.execute(get_quality_inspections_query(limit=1).where(PurchaseReceiptItem.id == receipt_item_id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="质检结果保存后读取失败")
return QualityInspectionRead.model_validate(dict(row))
def _read_quality_inspection_after_archive_generation(
db: Session,
receipt_item_id: int,
*,
created_by: int | None,
) -> QualityInspectionRead:
try:
archive_kwargs = {"created_by": created_by} if created_by is not None else {}
archive_result = generate_document_archive(
db,
DOCUMENT_TYPE_QUALITY_INSPECTION,
int(receipt_item_id),
**archive_kwargs,
)
archive_fields = {
"archive_status": archive_result.archive_status,
"archive_version": archive_result.archive_version,
"archive_error_message": archive_result.archive_error_message,
}
except Exception as exc:
try:
db.rollback()
except Exception:
pass
archive_fields = {
"archive_status": "归档失败",
"archive_version": None,
"archive_error_message": f"归档失败:{exc}",
}
return _read_quality_inspection(db, receipt_item_id).model_copy(update=archive_fields)
@router.get("/receipt-inspections", response_model=list[QualityInspectionRead])
def list_receipt_inspections(
pending_only: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[QualityInspectionRead]:
rows = db.execute(get_quality_inspections_query(limit=limit, pending_only=pending_only)).mappings().all()
return [QualityInspectionRead.model_validate(dict(row)) for row in rows]
@router.post("/receipt-inspections/{receipt_item_id}", response_model=QualityInspectionRead)
def inspect_receipt_item(
receipt_item_id: int,
payload: QualityInspectionAction,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> QualityInspectionRead:
receipt_item = db.get(PurchaseReceiptItem, receipt_item_id)
if not receipt_item:
raise HTTPException(status_code=404, detail="到货明细不存在")
lot = db.scalar(
select(StockLot).where(
StockLot.source_doc_type == "PURCHASE_RECEIPT",
StockLot.source_doc_id == receipt_item.receipt_id,
StockLot.source_line_id == receipt_item.id,
)
)
merged_into_existing_lot = False
if not lot and receipt_item.merge_to_lot_id:
lot = db.get(StockLot, receipt_item.merge_to_lot_id)
merged_into_existing_lot = True
if not lot:
raise HTTPException(status_code=404, detail="对应批次不存在")
po_item = db.get(PurchaseOrderItem, receipt_item.purchase_order_item_id)
if not po_item:
raise HTTPException(status_code=404, detail="采购订单明细不存在")
result = payload.result.upper()
if result not in {"PASS", "REJECT"}:
raise HTTPException(status_code=400, detail="质检结果只支持 PASS 或 REJECT")
if result == "PASS":
receipt_qty = to_decimal(receipt_item.received_qty)
receipt_weight = to_decimal(receipt_item.received_weight_kg)
receipt_item.accepted_qty = receipt_qty
receipt_item.accepted_weight_kg = receipt_weight
receipt_item.status = "PASSED"
lot.quality_status = "PASS"
lot.status = "AVAILABLE"
if merged_into_existing_lot:
lot.locked_qty = max(to_decimal(0), to_decimal(lot.locked_qty) - receipt_qty)
lot.locked_weight_kg = max(
to_decimal(0),
to_decimal(lot.locked_weight_kg) - receipt_weight,
)
else:
lot.locked_qty = to_decimal(0)
lot.locked_weight_kg = to_decimal(0)
upsert_stock_balance(
db,
item_id=lot.item_id,
warehouse_id=lot.warehouse_id,
location_id=lot.location_id,
qty_delta=to_decimal(0),
weight_delta=to_decimal(0),
available_qty_delta=receipt_qty,
available_weight_delta=receipt_weight,
unit_cost=to_decimal(lot.unit_cost, "0.0001"),
)
else:
receipt_item.accepted_qty = to_decimal(0)
receipt_item.accepted_weight_kg = to_decimal(0)
receipt_item.status = "REJECTED"
if merged_into_existing_lot:
reject_qty = to_decimal(receipt_item.received_qty)
reject_weight = to_decimal(receipt_item.received_weight_kg)
lot.inbound_qty = max(to_decimal(0), to_decimal(lot.inbound_qty) - reject_qty)
lot.inbound_weight_kg = max(to_decimal(0), to_decimal(lot.inbound_weight_kg) - reject_weight)
lot.remaining_qty = max(to_decimal(0), to_decimal(lot.remaining_qty) - reject_qty)
lot.remaining_weight_kg = max(to_decimal(0), to_decimal(lot.remaining_weight_kg) - reject_weight)
lot.locked_qty = max(to_decimal(0), to_decimal(lot.locked_qty) - reject_qty)
lot.locked_weight_kg = max(to_decimal(0), to_decimal(lot.locked_weight_kg) - reject_weight)
if lot.remaining_weight_kg <= 0 and lot.remaining_qty <= 0:
lot.status = "DEPLETED"
upsert_stock_balance(
db,
item_id=lot.item_id,
warehouse_id=lot.warehouse_id,
location_id=lot.location_id,
qty_delta=-reject_qty,
weight_delta=-reject_weight,
available_qty_delta=to_decimal(0),
available_weight_delta=to_decimal(0),
unit_cost=to_decimal(lot.unit_cost, "0.0001"),
)
else:
lot.quality_status = "REJECT"
lot.status = "REJECTED"
lot.locked_qty = to_decimal(lot.remaining_qty)
lot.locked_weight_kg = to_decimal(lot.remaining_weight_kg)
receipt_item.remark = payload.remark or receipt_item.remark
lot.remark = context.employee.employee_name
db.add(receipt_item)
db.add(lot)
db.flush()
po_item = recalc_purchase_order_item_received_weight(db, po_item.id) or po_item
if po_item.source_demand_id and po_item.status == "CLOSED":
demand = db.get(MaterialDemand, po_item.source_demand_id)
if demand:
demand.status = "RECEIVED"
db.add(demand)
sync_purchase_receipt_status(db, receipt_item.receipt_id)
sync_purchase_order_status(db, po_item.purchase_order_id)
db.commit()
return _read_quality_inspection_after_archive_generation(
db,
receipt_item_id,
created_by=_auth_context_user_id(context),
)
+798
View File
@@ -0,0 +1,798 @@
from datetime import datetime
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session, aliased
from app.db.session import get_db
from app.models.master_data import Item, Warehouse
from app.models.operations import Delivery, DeliveryItem, ReturnDisposition, ReturnItem, ReturnOrder, StockLot
from app.models.sales import Customer, SalesOrder, SalesOrderItem
from app.schemas.operations import (
ReturnDispositionCreate,
ReturnDispositionRead,
ReturnItemRead,
ReturnOrderCreate,
ReturnOrderRead,
ReturnScrapInboundCreate,
)
from app.services.auth import AuthContext, require_authenticated_user
from app.services.document_archives import DOCUMENT_TYPE_WAREHOUSE_OPERATION, generate_document_archive
from app.services.operations import (
create_inventory_txn,
create_rework_work_order,
get_location_or_default,
get_return_dispositions_query,
get_return_items_query,
get_return_orders_query,
sync_return_order_status,
to_decimal,
upsert_stock_balance,
)
from app.services.system_permissions import ensure_employee_has_permission
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
def _generate_return_archive_fields(
db: Session,
*,
business_id: int | None,
created_by: int | None,
) -> dict[str, object]:
if not business_id:
return {
"archive_status": None,
"archive_business_id": None,
"archive_document_type": None,
"archive_error_message": None,
}
try:
archive_result = generate_document_archive(
db,
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
int(business_id),
created_by=created_by,
)
return {
"archive_status": archive_result.archive_status,
"archive_business_id": int(business_id),
"archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION,
"archive_error_message": archive_result.archive_error_message,
}
except Exception:
return {
"archive_status": "归档失败",
"archive_business_id": int(business_id),
"archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION,
"archive_error_message": "归档失败:PDF归档服务异常,请稍后重新生成",
}
def _apply_archive_fields_to_model(model, archive_fields: dict[str, object]):
model.archive_status = archive_fields["archive_status"]
model.archive_business_id = archive_fields["archive_business_id"]
model.archive_document_type = archive_fields["archive_document_type"]
model.archive_error_message = archive_fields["archive_error_message"]
return model
def get_return_warehouse(db: Session, warehouse_id: int | None) -> Warehouse:
if warehouse_id:
warehouse = db.get(Warehouse, warehouse_id)
else:
warehouse = db.scalar(select(Warehouse).where(Warehouse.warehouse_type == "RETURN", Warehouse.status == "ACTIVE"))
if not warehouse:
raise HTTPException(status_code=404, detail="退货仓不存在")
if str(warehouse.warehouse_type or "").upper() != "RETURN":
raise HTTPException(status_code=400, detail="退货入库只能进入退货库")
if str(warehouse.status or "").upper() != "ACTIVE":
raise HTTPException(status_code=400, detail="退货仓未启用,不能办理退货入库")
return warehouse
def _serial_code_part(value: str | None, fallback: str, max_length: int = 14) -> str:
token = "".join(ch for ch in str(value or "").upper() if ch.isalnum())
return (token or fallback)[-max_length:]
def build_return_no(db: Session, customer: Customer) -> str:
date_part = datetime.now().strftime("%Y%m%d")
customer_part = _serial_code_part(customer.short_name or customer.customer_code, f"CUS{customer.id:04d}")
prefix = f"RTN-NB-{date_part}-{customer_part}"
count = db.scalar(select(func.count(ReturnOrder.id)).where(ReturnOrder.return_no.like(f"{prefix}-%"))) or 0
return f"{prefix}-{count + 1:03d}"
def build_return_disposition_no(db: Session, return_order: ReturnOrder, disposition_type: str) -> str:
date_part = datetime.now().strftime("%Y%m%d")
type_part = "RWK" if disposition_type == "REWORK" else "SCRAP"
return_part = _serial_code_part(return_order.return_no, f"RTN{return_order.id:04d}", 18)
prefix = f"RTD-NB-{type_part}-{date_part}-{return_part}"
count = db.scalar(select(func.count(ReturnDisposition.id)).where(ReturnDisposition.disposition_no.like(f"{prefix}-%"))) or 0
return f"{prefix}-{count + 1:03d}"
def build_return_lot_no(db: Session, return_order: ReturnOrder, product: Item, line_no: int) -> str:
date_part = datetime.now().strftime("%Y%m%d")
return_part = _serial_code_part(return_order.return_no, f"RTN{return_order.id:04d}", 12)
product_part = _serial_code_part(product.item_code or product.item_name, f"FG{product.id:04d}", 10)
base_lot_no = f"RTL-NB-{date_part}-{return_part}-{product_part}-L{line_no:02d}"
candidate = base_lot_no
suffix = 1
while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)):
suffix += 1
candidate = f"{base_lot_no}-{suffix:02d}"
return candidate
def get_delivery_item_trace(db: Session, delivery_item_id: int) -> dict:
source_lot = aliased(StockLot)
source_material_lot = aliased(StockLot)
source_material_item = aliased(Item)
row = db.execute(
select(
DeliveryItem.id.label("delivery_item_id"),
Delivery.id.label("delivery_id"),
Delivery.delivery_no.label("delivery_no"),
Delivery.status.label("delivery_status"),
Delivery.customer_id.label("customer_id"),
Delivery.sales_order_id.label("sales_order_id"),
DeliveryItem.sales_order_item_id.label("sales_order_item_id"),
DeliveryItem.product_item_id.label("product_item_id"),
DeliveryItem.status.label("delivery_item_status"),
source_lot.id.label("source_lot_id"),
DeliveryItem.delivery_qty.label("delivery_qty"),
DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"),
source_lot.lot_no.label("source_lot_no"),
source_lot.source_material_lot_id.label("source_material_lot_id"),
source_material_lot.lot_no.label("source_material_lot_no"),
source_material_item.item_code.label("source_material_code"),
source_material_item.item_name.label("source_material_name"),
source_lot.source_material_sub_batch_no.label("source_material_sub_batch_no"),
source_lot.source_material_summary.label("source_material_summary"),
)
.join(Delivery, Delivery.id == DeliveryItem.delivery_id)
.join(source_lot, source_lot.id == DeliveryItem.lot_id)
.outerjoin(source_material_lot, source_material_lot.id == source_lot.source_material_lot_id)
.outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id)
.where(DeliveryItem.id == delivery_item_id)
).mappings().first()
if not row:
raise HTTPException(status_code=404, detail="发货批次不存在")
return dict(row)
def _returned_totals_for_delivery_item(db: Session, delivery_item_id: int) -> tuple[Decimal, Decimal]:
row = db.execute(
select(
func.coalesce(func.sum(ReturnItem.return_qty), 0).label("returned_qty"),
func.coalesce(func.sum(ReturnItem.return_weight_kg), 0).label("returned_weight"),
).where(ReturnItem.delivery_item_id == delivery_item_id)
).mappings().first()
return to_decimal(row["returned_qty"]), to_decimal(row["returned_weight"])
def _lock_delivery_items_for_return(db: Session, delivery_item_ids: set[int]) -> None:
ids = sorted(int(item_id) for item_id in delivery_item_ids if item_id)
if not ids:
return
db.execute(select(DeliveryItem.id).where(DeliveryItem.id.in_(ids)).with_for_update()).all()
def _ensure_return_amount_available(
db: Session,
*,
trace: dict,
requested_qty: Decimal,
requested_weight: Decimal,
staged_qty: Decimal = Decimal("0"),
staged_weight: Decimal = Decimal("0"),
) -> None:
returned_qty, returned_weight = _returned_totals_for_delivery_item(db, int(trace["delivery_item_id"]))
delivery_qty = to_decimal(trace["delivery_qty"])
delivery_weight = to_decimal(trace["delivery_weight_kg"])
if delivery_qty > 0 and returned_qty + staged_qty + requested_qty > delivery_qty:
raise HTTPException(status_code=400, detail="退货数量超过发货批次可退数量")
if delivery_weight > 0 and returned_weight + staged_weight + requested_weight > delivery_weight:
raise HTTPException(status_code=400, detail="退货重量超过发货批次可退重量")
def _validate_return_delivery_trace(
item_payload,
trace: dict,
*,
customer_id: int,
delivery_id: int | None,
sales_order_id: int | None,
) -> None:
if str(trace.get("delivery_status") or "").upper() != "POSTED":
raise HTTPException(status_code=400, detail="发货单未过账,不能办理退货")
if str(trace.get("delivery_item_status") or "").upper() != "POSTED":
raise HTTPException(status_code=400, detail="发货批次未过账,不能办理退货")
if trace["customer_id"] != customer_id:
raise HTTPException(status_code=400, detail="发货批次客户与退货客户不一致")
if delivery_id and trace["delivery_id"] != delivery_id:
raise HTTPException(status_code=400, detail="发货批次不属于所选发货单")
if sales_order_id and trace["sales_order_id"] and trace["sales_order_id"] != sales_order_id:
raise HTTPException(status_code=400, detail="发货批次不属于所选订单")
if item_payload.product_item_id != trace["product_item_id"]:
raise HTTPException(status_code=400, detail="退货产品与发货批次不一致")
if item_payload.source_lot_id and item_payload.source_lot_id != trace["source_lot_id"]:
raise HTTPException(status_code=400, detail="退货来源批次与发货批次不一致")
if (
getattr(item_payload, "sales_order_item_id", None)
and trace["sales_order_item_id"]
and item_payload.sales_order_item_id != trace["sales_order_item_id"]
):
raise HTTPException(status_code=400, detail="退货订单明细与发货批次不一致")
@router.get("/orders", response_model=list[ReturnOrderRead])
def list_return_orders(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[ReturnOrderRead]:
rows = db.execute(get_return_orders_query(limit=limit)).mappings().all()
return [ReturnOrderRead.model_validate(dict(row)) for row in rows]
@router.get("/items", response_model=list[ReturnItemRead])
def list_return_items(
return_order_id: int | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[ReturnItemRead]:
rows = db.execute(get_return_items_query(limit=limit, return_order_id=return_order_id)).mappings().all()
return [ReturnItemRead.model_validate(dict(row)) for row in rows]
@router.post("/orders", response_model=ReturnOrderRead)
def create_return_order(
payload: ReturnOrderCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> ReturnOrderRead:
customer = db.get(Customer, payload.customer_id)
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
if payload.sales_order_id and not db.get(SalesOrder, payload.sales_order_id):
raise HTTPException(status_code=404, detail="订单不存在")
if payload.delivery_id and not db.get(Delivery, payload.delivery_id):
raise HTTPException(status_code=404, detail="发货单不存在")
if not payload.items:
raise HTTPException(status_code=400, detail="退货单至少需要一条明细")
warehouse = get_return_warehouse(db, payload.warehouse_id)
prepared_items = []
staged_by_delivery_item: dict[int, tuple[Decimal, Decimal]] = {}
order_delivery_id = payload.delivery_id
order_sales_order_id = payload.sales_order_id
for item_payload in payload.items:
if not item_payload.delivery_item_id:
raise HTTPException(status_code=400, detail="退货入库必须选择发货批次")
trace = get_delivery_item_trace(db, item_payload.delivery_item_id)
if order_delivery_id is None:
order_delivery_id = trace["delivery_id"]
if order_sales_order_id is None:
order_sales_order_id = trace["sales_order_id"]
_validate_return_delivery_trace(
item_payload,
trace,
customer_id=payload.customer_id,
delivery_id=order_delivery_id,
sales_order_id=order_sales_order_id,
)
product = db.get(Item, item_payload.product_item_id)
if not product:
raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}")
if item_payload.sales_order_item_id:
order_item = db.get(SalesOrderItem, item_payload.sales_order_item_id)
if not order_item:
raise HTTPException(status_code=404, detail=f"订单明细不存在: {item_payload.sales_order_item_id}")
return_qty = to_decimal(item_payload.return_qty)
if return_qty <= 0:
raise HTTPException(status_code=400, detail="退货数量必须大于0")
return_weight = to_decimal(item_payload.return_weight_kg)
if return_weight <= 0:
return_weight = return_qty * to_decimal(product.unit_weight_kg)
if return_weight <= 0:
raise HTTPException(status_code=400, detail="退货重量必须大于0")
staged_qty, staged_weight = staged_by_delivery_item.get(
int(item_payload.delivery_item_id),
(Decimal("0"), Decimal("0")),
)
_ensure_return_amount_available(
db,
trace=trace,
requested_qty=return_qty,
requested_weight=return_weight,
staged_qty=staged_qty,
staged_weight=staged_weight,
)
staged_by_delivery_item[int(item_payload.delivery_item_id)] = (
staged_qty + return_qty,
staged_weight + return_weight,
)
prepared_items.append((item_payload, trace, product, return_qty, return_weight))
ensure_employee_has_permission(
db,
payload.handler_employee_id,
"MENU_INVENTORY_LEDGER",
"经办人",
required=False,
)
_lock_delivery_items_for_return(db, {int(item.delivery_item_id) for item, *_ in prepared_items})
staged_by_delivery_item = {}
for item_payload, trace, _product, return_qty, return_weight in prepared_items:
staged_qty, staged_weight = staged_by_delivery_item.get(
int(item_payload.delivery_item_id),
(Decimal("0"), Decimal("0")),
)
_ensure_return_amount_available(
db,
trace=trace,
requested_qty=return_qty,
requested_weight=return_weight,
staged_qty=staged_qty,
staged_weight=staged_weight,
)
staged_by_delivery_item[int(item_payload.delivery_item_id)] = (
staged_qty + return_qty,
staged_weight + return_weight,
)
order = ReturnOrder(
return_no=build_return_no(db, customer),
customer_id=payload.customer_id,
sales_order_id=order_sales_order_id,
delivery_id=order_delivery_id,
return_date=payload.return_date or datetime.now(),
return_reason=payload.return_reason,
handler_employee_id=payload.handler_employee_id,
status="CREATED",
remark=payload.remark,
)
db.add(order)
db.flush()
location = get_location_or_default(db, warehouse.id, None)
first_inventory_txn_id: int | None = None
for index, (item_payload, trace, product, return_qty, return_weight) in enumerate(prepared_items, start=1):
return_item = ReturnItem(
return_order_id=order.id,
line_no=index,
sales_order_item_id=item_payload.sales_order_item_id or trace["sales_order_item_id"],
delivery_item_id=item_payload.delivery_item_id,
product_item_id=item_payload.product_item_id,
source_lot_id=trace["source_lot_id"],
return_qty=return_qty,
return_weight_kg=return_weight,
disposition_status="PENDING",
responsibility_type=item_payload.responsibility_type,
remark=item_payload.remark,
)
db.add(return_item)
db.flush()
lot = StockLot(
lot_no=build_return_lot_no(db, order, product, index),
parent_lot_id=trace["source_lot_id"],
lot_role="RETURNED_FINISHED",
material_sub_batch_no=None,
item_id=item_payload.product_item_id,
warehouse_id=warehouse.id,
location_id=location.id if location else None,
source_doc_type="RETURN_ORDER",
source_doc_id=order.id,
source_line_id=return_item.id,
source_material_lot_id=trace["source_material_lot_id"],
source_material_sub_batch_no=trace["source_material_sub_batch_no"],
source_material_summary=trace["source_material_summary"],
inbound_qty=return_qty,
inbound_weight_kg=return_weight,
remaining_qty=return_qty,
remaining_weight_kg=return_weight,
locked_qty=return_qty,
locked_weight_kg=return_weight,
unit_cost=to_decimal(0, "0.0001"),
production_date=None,
expire_date=None,
quality_status="PENDING",
status="LOCKED",
remark="客户退货待处置",
)
db.add(lot)
db.flush()
upsert_stock_balance(
db,
item_id=item_payload.product_item_id,
warehouse_id=warehouse.id,
location_id=location.id if location else None,
qty_delta=return_qty,
weight_delta=return_weight,
available_qty_delta=to_decimal(0),
available_weight_delta=to_decimal(0),
unit_cost=to_decimal(0, "0.0001"),
)
txn = create_inventory_txn(
db,
txn_type="RETURN_IN",
item_id=item_payload.product_item_id,
warehouse_id=warehouse.id,
location_id=location.id if location else None,
lot_id=lot.id,
qty_change=return_qty,
weight_change=return_weight,
unit_cost=to_decimal(0, "0.0001"),
source_doc_type="RETURN_ORDER",
source_doc_id=order.id,
source_line_id=return_item.id,
biz_time=order.return_date,
operator_user_id=context.user.id,
remark="客户退货入库",
amount_basis="QTY",
)
if first_inventory_txn_id is None:
first_inventory_txn_id = int(txn.id)
db.commit()
archive_fields = _generate_return_archive_fields(db, business_id=first_inventory_txn_id, created_by=context.user.id)
row = db.execute(get_return_orders_query(limit=1).where(ReturnOrder.id == order.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="退货单创建后读取失败")
return _apply_archive_fields_to_model(ReturnOrderRead.model_validate(dict(row)), archive_fields)
@router.post("/scrap-inbound", response_model=ReturnOrderRead)
def create_return_scrap_inbound(
payload: ReturnScrapInboundCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> ReturnOrderRead:
customer = db.get(Customer, payload.customer_id)
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
delivery = db.get(Delivery, payload.delivery_id)
if not delivery:
raise HTTPException(status_code=404, detail="发货单不存在")
warehouse = db.get(Warehouse, payload.warehouse_id)
if not warehouse:
raise HTTPException(status_code=404, detail="废料仓不存在")
if str(warehouse.warehouse_type or "").upper() != "SCRAP":
raise HTTPException(status_code=400, detail="退货报废入库只能进入废料库")
if str(warehouse.status or "").upper() != "ACTIVE":
raise HTTPException(status_code=400, detail="废料仓未启用,不能办理退货报废入库")
if not payload.items:
raise HTTPException(status_code=400, detail="退货报废入库至少需要一条明细")
prepared_items = []
staged_by_delivery_item: dict[int, tuple[Decimal, Decimal]] = {}
for item_payload in payload.items:
trace = get_delivery_item_trace(db, item_payload.delivery_item_id)
_validate_return_delivery_trace(
item_payload,
trace,
customer_id=payload.customer_id,
delivery_id=payload.delivery_id,
sales_order_id=delivery.sales_order_id,
)
product = db.get(Item, item_payload.product_item_id)
if not product:
raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}")
scrap_weight = to_decimal(item_payload.scrap_weight_kg)
if scrap_weight <= 0:
raise HTTPException(status_code=400, detail="退货报废入库重量必须大于0")
scrap_qty = to_decimal(item_payload.scrap_qty)
unit_weight = to_decimal(product.unit_weight_kg)
if scrap_qty <= 0 and unit_weight > 0:
scrap_qty = scrap_weight / unit_weight
if scrap_qty <= 0:
raise HTTPException(status_code=400, detail="退货报废数量必须大于0")
staged_qty, staged_weight = staged_by_delivery_item.get(
int(item_payload.delivery_item_id),
(Decimal("0"), Decimal("0")),
)
_ensure_return_amount_available(
db,
trace=trace,
requested_qty=scrap_qty,
requested_weight=scrap_weight,
staged_qty=staged_qty,
staged_weight=staged_weight,
)
staged_by_delivery_item[int(item_payload.delivery_item_id)] = (
staged_qty + scrap_qty,
staged_weight + scrap_weight,
)
unit_cost = (
to_decimal(item_payload.unit_cost, "0.0001")
if item_payload.unit_cost is not None
else to_decimal(product.scrap_sale_price or 0, "0.0001")
)
prepared_items.append((item_payload, trace, product, scrap_qty, scrap_weight, unit_cost))
ensure_employee_has_permission(
db,
payload.handler_employee_id,
"MENU_INVENTORY_LEDGER",
"经办人",
required=False,
)
_lock_delivery_items_for_return(db, {int(item.delivery_item_id) for item, *_ in prepared_items})
staged_by_delivery_item = {}
for item_payload, trace, _product, scrap_qty, scrap_weight, _unit_cost in prepared_items:
staged_qty, staged_weight = staged_by_delivery_item.get(
int(item_payload.delivery_item_id),
(Decimal("0"), Decimal("0")),
)
_ensure_return_amount_available(
db,
trace=trace,
requested_qty=scrap_qty,
requested_weight=scrap_weight,
staged_qty=staged_qty,
staged_weight=staged_weight,
)
staged_by_delivery_item[int(item_payload.delivery_item_id)] = (
staged_qty + scrap_qty,
staged_weight + scrap_weight,
)
inbound_time = payload.inbound_date or datetime.now()
order = ReturnOrder(
return_no=build_return_no(db, customer),
customer_id=payload.customer_id,
sales_order_id=delivery.sales_order_id,
delivery_id=payload.delivery_id,
return_date=inbound_time,
return_reason=payload.return_reason,
handler_employee_id=payload.handler_employee_id,
status="CLOSED",
remark=payload.remark,
)
db.add(order)
db.flush()
location = get_location_or_default(db, warehouse.id, None)
first_inventory_txn_id: int | None = None
for index, (item_payload, trace, product, scrap_qty, scrap_weight, unit_cost) in enumerate(prepared_items, start=1):
return_item = ReturnItem(
return_order_id=order.id,
line_no=index,
sales_order_item_id=trace["sales_order_item_id"],
delivery_item_id=item_payload.delivery_item_id,
product_item_id=item_payload.product_item_id,
source_lot_id=trace["source_lot_id"],
return_qty=scrap_qty,
return_weight_kg=scrap_weight,
disposition_status="CLOSED",
responsibility_type=None,
remark=item_payload.remark,
)
db.add(return_item)
db.flush()
lot = StockLot(
lot_no=build_return_lot_no(db, order, product, index).replace("RTL", "RTS", 1),
parent_lot_id=trace["source_lot_id"],
lot_role="CUSTOMER_RETURN_SCRAP",
material_sub_batch_no=None,
item_id=item_payload.product_item_id,
warehouse_id=warehouse.id,
location_id=location.id if location else None,
source_doc_type="RETURN_ORDER",
source_doc_id=order.id,
source_line_id=return_item.id,
source_material_lot_id=trace["source_material_lot_id"],
source_material_sub_batch_no=trace["source_material_sub_batch_no"],
source_material_summary=trace["source_material_summary"],
inbound_qty=scrap_qty,
inbound_weight_kg=scrap_weight,
remaining_qty=scrap_qty,
remaining_weight_kg=scrap_weight,
locked_qty=to_decimal(0),
locked_weight_kg=to_decimal(0),
unit_cost=unit_cost,
production_date=None,
expire_date=None,
quality_status="SCRAP",
status="AVAILABLE",
remark="客户退货直接报废入库",
)
db.add(lot)
db.flush()
upsert_stock_balance(
db,
item_id=item_payload.product_item_id,
warehouse_id=warehouse.id,
location_id=location.id if location else None,
qty_delta=scrap_qty,
weight_delta=scrap_weight,
available_qty_delta=scrap_qty,
available_weight_delta=scrap_weight,
unit_cost=unit_cost,
)
txn = create_inventory_txn(
db,
txn_type="RETURN_SCRAP_IN",
item_id=item_payload.product_item_id,
warehouse_id=warehouse.id,
location_id=location.id if location else None,
lot_id=lot.id,
qty_change=scrap_qty,
weight_change=scrap_weight,
unit_cost=unit_cost,
source_doc_type="RETURN_ORDER",
source_doc_id=order.id,
source_line_id=return_item.id,
biz_time=inbound_time,
operator_user_id=context.user.id,
remark="客户退货报废入库",
amount_basis="WEIGHT",
)
if first_inventory_txn_id is None:
first_inventory_txn_id = int(txn.id)
disposition = ReturnDisposition(
disposition_no=build_return_disposition_no(db, order, "SCRAP"),
return_item_id=return_item.id,
disposition_type="SCRAP",
rework_work_order_id=None,
scrap_weight_kg=scrap_weight,
extra_cost_amount=to_decimal(0, "0.01"),
scrap_sale_amount=to_decimal(scrap_weight * unit_cost, "0.01"),
closed_at=datetime.now(),
status="CLOSED",
remark=item_payload.remark or payload.remark,
)
db.add(disposition)
db.commit()
archive_fields = _generate_return_archive_fields(db, business_id=first_inventory_txn_id, created_by=context.user.id)
row = db.execute(get_return_orders_query(limit=1).where(ReturnOrder.id == order.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="退货报废入库创建后读取失败")
return _apply_archive_fields_to_model(ReturnOrderRead.model_validate(dict(row)), archive_fields)
@router.get("/dispositions", response_model=list[ReturnDispositionRead])
def list_return_dispositions(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[ReturnDispositionRead]:
rows = db.execute(get_return_dispositions_query(limit=limit)).mappings().all()
return [ReturnDispositionRead.model_validate(dict(row)) for row in rows]
@router.post("/dispositions", response_model=ReturnDispositionRead)
def create_return_disposition(
payload: ReturnDispositionCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> ReturnDispositionRead:
return_item = db.scalar(select(ReturnItem).where(ReturnItem.id == payload.return_item_id).with_for_update())
if not return_item:
raise HTTPException(status_code=404, detail="退货明细不存在")
if return_item.disposition_status == "CLOSED":
raise HTTPException(status_code=400, detail="该退货明细已完成处置")
return_order = db.get(ReturnOrder, return_item.return_order_id)
if not return_order:
raise HTTPException(status_code=404, detail="退货单不存在")
lot = db.scalar(
select(StockLot).where(
StockLot.source_doc_type == "RETURN_ORDER",
StockLot.source_doc_id == return_item.return_order_id,
StockLot.source_line_id == return_item.id,
).with_for_update()
)
if not lot:
raise HTTPException(status_code=404, detail="退货批次不存在")
disposition_type = payload.disposition_type.upper()
if disposition_type != "REWORK":
raise HTTPException(status_code=400, detail="退货库处置只支持返工,报废退货请从废料库办理退货废料入库")
warehouse = db.get(Warehouse, lot.warehouse_id)
if str(warehouse.warehouse_type if warehouse else "").upper() != "RETURN":
raise HTTPException(status_code=400, detail="退货批次不在退货库,不能返工出库")
if lot.lot_role != "RETURNED_FINISHED":
raise HTTPException(status_code=400, detail="退货批次类型不正确,不能返工出库")
if lot.status != "LOCKED" or lot.quality_status != "PENDING":
raise HTTPException(status_code=400, detail="退货批次状态不是待返工锁定状态")
required_qty = to_decimal(return_item.return_qty)
required_weight = to_decimal(return_item.return_weight_kg)
if to_decimal(lot.remaining_qty) < required_qty or to_decimal(lot.locked_qty) < required_qty:
raise HTTPException(status_code=400, detail="退货批次数量不足,不能返工出库")
if to_decimal(lot.remaining_weight_kg) < required_weight or to_decimal(lot.locked_weight_kg) < required_weight:
raise HTTPException(status_code=400, detail="退货批次重量不足,不能返工出库")
rework_work_order_id = None
if disposition_type == "REWORK":
rework_order = create_rework_work_order(
db,
product_item_id=return_item.product_item_id,
planned_qty=to_decimal(return_item.return_qty),
remark=payload.remark or "由退货处置自动生成返工工单",
)
rework_work_order_id = rework_order.id
lot.remaining_qty = to_decimal(0)
lot.remaining_weight_kg = to_decimal(0)
lot.locked_qty = to_decimal(0)
lot.locked_weight_kg = to_decimal(0)
lot.status = "CLOSED"
lot.quality_status = "REWORK"
db.add(lot)
disposition = ReturnDisposition(
disposition_no=build_return_disposition_no(db, return_order, disposition_type),
return_item_id=payload.return_item_id,
disposition_type=disposition_type,
rework_work_order_id=rework_work_order_id,
scrap_weight_kg=to_decimal(payload.scrap_weight_kg),
extra_cost_amount=to_decimal(payload.extra_cost_amount, "0.01"),
scrap_sale_amount=to_decimal(payload.scrap_sale_amount, "0.01"),
closed_at=datetime.now(),
status="CLOSED",
remark=payload.remark,
)
db.add(disposition)
db.flush()
upsert_stock_balance(
db,
item_id=lot.item_id,
warehouse_id=lot.warehouse_id,
location_id=lot.location_id,
qty_delta=-required_qty,
weight_delta=-required_weight,
available_qty_delta=to_decimal(0),
available_weight_delta=to_decimal(0),
unit_cost=to_decimal(lot.unit_cost, "0.0001"),
)
txn = create_inventory_txn(
db,
txn_type="RETURN_OUT",
item_id=lot.item_id,
warehouse_id=lot.warehouse_id,
location_id=lot.location_id,
lot_id=lot.id,
qty_change=-required_qty,
weight_change=-required_weight,
unit_cost=to_decimal(lot.unit_cost, "0.0001"),
source_doc_type="RETURN_DISPOSITION",
source_doc_id=disposition.id,
source_line_id=disposition.id,
biz_time=disposition.closed_at or datetime.now(),
operator_user_id=context.user.id,
remark="退货处置出库",
amount_basis="QTY",
)
return_item.disposition_status = "CLOSED"
db.add(return_item)
sync_return_order_status(db, return_item.return_order_id)
db.commit()
archive_fields = _generate_return_archive_fields(db, business_id=int(txn.id), created_by=context.user.id)
row = db.execute(get_return_dispositions_query(limit=1).where(ReturnDisposition.id == disposition.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="退货处置创建后读取失败")
return _apply_archive_fields_to_model(ReturnDispositionRead.model_validate(dict(row)), archive_fields)
@router.post("/rework-outbound", response_model=ReturnDispositionRead)
def create_return_rework_outbound(
payload: ReturnDispositionCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> ReturnDispositionRead:
if payload.disposition_type.upper() != "REWORK":
raise HTTPException(status_code=400, detail="退货库出库只支持返工出库")
return create_return_disposition(payload, context=context, db=db)
+789
View File
@@ -0,0 +1,789 @@
import re
from datetime import date, datetime
from io import BytesIO
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from openpyxl import Workbook, load_workbook
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.master_data import Item, Warehouse
from app.models.operations import Delivery, DeliveryItem, PurchaseOrderItem, PurchaseOrderSalesOrderLink, ReturnOrder, StockLot, WorkOrder
from app.models.planning import MaterialDemand
from app.models.sales import Customer, SalesOrder, SalesOrderItem
from app.schemas.database import (
CustomerCreate,
CustomerRead,
SalesOrderCreate,
SalesOrderCreateResult,
SalesOrderItemRead,
SalesOrderRead,
)
from app.schemas.operations import DeliveryCreate, DeliveryItemRead, DeliveryRead
from app.services.auth import AuthContext, require_authenticated_user
from app.services.contacts import normalize_contacts, parse_contacts, serialize_contacts
from app.services.document_archives import DOCUMENT_TYPE_SALES_ORDER, DOCUMENT_TYPE_WAREHOUSE_OPERATION, generate_document_archive
from app.services.logistics import normalize_logistics_fields
from app.services.operations import (
create_inventory_txn,
get_delivery_items_query,
get_deliveries_query,
sync_sales_order_status,
to_decimal,
upsert_stock_balance,
)
from app.services.purchase_order_links import ensure_purchase_order_sales_link_table
from app.services.stocktake import ensure_warehouses_unlocked
from app.services.system_permissions import ensure_employee_has_permission
from app.services.sales_planning import (
create_customer_code,
decimalize,
get_sales_order_items_query,
get_sales_order_query,
)
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
def _auth_context_user_id(context: AuthContext | object) -> int | None:
user = getattr(context, "user", None)
user_id = getattr(user, "id", None)
return int(user_id) if user_id is not None else None
def _generate_delivery_archive_fields(
db: Session,
*,
business_id: int | None,
created_by: int | None,
) -> dict[str, object]:
if not business_id:
return {
"archive_status": None,
"archive_business_id": None,
"archive_document_type": None,
"archive_error_message": None,
}
try:
archive_result = generate_document_archive(
db,
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
int(business_id),
created_by=created_by,
)
return {
"archive_status": archive_result.archive_status,
"archive_business_id": int(business_id),
"archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION,
"archive_error_message": archive_result.archive_error_message,
}
except Exception:
return {
"archive_status": "归档失败",
"archive_business_id": int(business_id),
"archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION,
"archive_error_message": "归档失败:PDF归档服务异常,请稍后重新生成",
}
def _generate_delivery_archives_for_txns(
db: Session,
inventory_txn_ids: list[int],
*,
created_by: int | None,
) -> dict[str, object]:
first_archive_fields: dict[str, object] | None = None
for inventory_txn_id in inventory_txn_ids:
archive_fields = _generate_delivery_archive_fields(db, business_id=inventory_txn_id, created_by=created_by)
if first_archive_fields is None:
first_archive_fields = archive_fields
return first_archive_fields or _generate_delivery_archive_fields(db, business_id=None, created_by=created_by)
def _delivery_read_with_archive(row: dict[str, Any], archive_fields: dict[str, object]) -> DeliveryRead:
result = DeliveryRead.model_validate(dict(row))
result.archive_status = archive_fields["archive_status"]
result.archive_business_id = archive_fields["archive_business_id"]
result.archive_document_type = archive_fields["archive_document_type"]
result.archive_error_message = archive_fields["archive_error_message"]
return result
CUSTOMER_EXCEL_HEADERS = ["客户名称", "客户简称", "联系人1", "联系电话1", "联系人2", "联系电话2", "联系人3", "联系电话3", "送货地址", "账期(天)"]
CUSTOMER_EXCEL_REQUIRED_HEADERS = ["客户名称"]
def _serial_code_part(value: str | None, fallback: str) -> str:
code = "".join(ch for ch in str(value or "").upper() if ch.isalnum())
return (code[-8:] if code else fallback)[-8:]
def build_sales_order_no(db: Session, customer: Customer) -> str:
_ = customer
year_part = date.today().strftime("%Y")
prefix = f"销售{year_part}"
pattern = re.compile(rf"{re.escape(prefix)}-(\d{{5}})")
max_no = 0
rows = db.scalars(select(SalesOrder.order_no).where(SalesOrder.order_no.like(f"{prefix}-%"))).all()
for order_no in rows:
match = pattern.fullmatch(str(order_no or "").strip())
if match:
max_no = max(max_no, int(match.group(1)))
return f"{prefix}-{max_no + 1:05d}"
def build_delivery_no(db: Session, sales_order: SalesOrder | None = None, customer: Customer | None = None) -> str:
date_part = date.today().strftime("%Y%m%d")
biz_part = _serial_code_part(
sales_order.order_no if sales_order else (customer.short_name or customer.customer_code if customer else None),
f"CUS{customer.id:04d}" if customer else "DIRECT",
)
prefix = f"DLV-NB-{date_part}-{biz_part}"
count = db.scalar(select(func.count(Delivery.id)).where(Delivery.delivery_no.like(f"{prefix}-%"))) or 0
return f"{prefix}-{count + 1:03d}"
def _customer_contact_fields(payload: CustomerCreate) -> tuple[str | None, str | None]:
try:
contacts = normalize_contacts(
list(payload.contacts or []),
fallback_name=payload.contact_name,
fallback_phone=payload.contact_phone,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return serialize_contacts(contacts)
def _customer_read(customer: Customer) -> CustomerRead:
return CustomerRead.model_validate(
{
"id": customer.id,
"customer_code": customer.customer_code,
"customer_name": customer.customer_name,
"short_name": customer.short_name,
"contact_name": customer.contact_name,
"contact_phone": customer.contact_phone,
"contacts": parse_contacts(customer.contact_name, customer.contact_phone),
"address": customer.address,
"credit_days": customer.credit_days,
"status": customer.status,
}
)
def _clean_excel_text(value: Any) -> str:
return str(value or "").strip()
def _excel_int(value: Any, default: int | None = None) -> int | None:
text = _clean_excel_text(value)
if not text:
return default
try:
return int(float(text))
except (TypeError, ValueError) as exc:
raise ValueError(f"数字格式不正确:{text}") from exc
def _excel_workbook_response(workbook: Workbook, filename: str, ascii_filename: str) -> Response:
output = BytesIO()
workbook.save(output)
encoded_filename = quote(filename)
return Response(
content=output.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}"},
)
def _build_customer_workbook(rows: list[Customer]) -> Workbook:
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "客户名录"
worksheet.freeze_panes = "A2"
worksheet.append(CUSTOMER_EXCEL_HEADERS)
for row in rows:
contacts = parse_contacts(row.contact_name, row.contact_phone)
worksheet.append(
[
row.customer_name,
row.short_name or "",
contacts[0]["contact_name"] if len(contacts) > 0 else "",
contacts[0]["contact_phone"] if len(contacts) > 0 else "",
contacts[1]["contact_name"] if len(contacts) > 1 else "",
contacts[1]["contact_phone"] if len(contacts) > 1 else "",
contacts[2]["contact_name"] if len(contacts) > 2 else "",
contacts[2]["contact_phone"] if len(contacts) > 2 else "",
row.address or "",
int(row.credit_days or 0),
]
)
for index, width in enumerate([30, 18, 18, 20, 18, 20, 18, 20, 46, 12], start=1):
worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width
return workbook
def _read_customer_excel_rows(content: bytes) -> list[dict[str, Any]]:
try:
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
except Exception as exc:
raise ValueError("Excel 文件读取失败,请确认上传的是客户名录 xlsx 文件") from exc
worksheet = workbook.active
rows = list(worksheet.iter_rows(values_only=True))
if not rows:
return []
headers = [_clean_excel_text(value) for value in rows[0]]
missing = [header for header in CUSTOMER_EXCEL_REQUIRED_HEADERS if header not in headers]
if missing:
raise ValueError(f"Excel 缺少客户名录必要列:{'、'.join(missing)}")
return [{headers[index]: value for index, value in enumerate(row) if index < len(headers)} for row in rows[1:]]
def _import_customer_excel(db: Session, content: bytes) -> dict[str, object]:
rows = _read_customer_excel_rows(content)
if not rows:
raise ValueError("Excel 中没有可导入的客户数据")
latest_by_name: dict[str, dict[str, Any]] = {}
skipped = 0
errors: list[str] = []
for index, row in enumerate(rows, start=2):
if not any(_clean_excel_text(value) for value in row.values()):
continue
customer_name = _clean_excel_text(row.get("客户名称"))
if not customer_name:
skipped += 1
errors.append(f"第{index}行缺少客户名称")
continue
try:
credit_days = _excel_int(row.get("账期(天)"), None)
except ValueError as exc:
skipped += 1
errors.append(f"第{index}行{exc}")
continue
latest_by_name[customer_name] = {
"customer_name": customer_name,
"short_name": _clean_excel_text(row.get("客户简称")),
"contacts": normalize_contacts(
[
{"contact_name": row.get("联系人1"), "contact_phone": row.get("联系电话1")},
{"contact_name": row.get("联系人2"), "contact_phone": row.get("联系电话2")},
{"contact_name": row.get("联系人3"), "contact_phone": row.get("联系电话3")},
],
fallback_name=_clean_excel_text(row.get("联系人")),
fallback_phone=_clean_excel_text(row.get("联系电话")),
),
"address": _clean_excel_text(row.get("送货地址") if "送货地址" in row else row.get("地址")),
"credit_days": credit_days,
}
if not latest_by_name:
raise ValueError("Excel 中没有可导入的有效客户数据")
existing_rows = db.scalars(
select(Customer)
.where(Customer.customer_name.in_(latest_by_name.keys()))
.order_by(Customer.id.asc())
).all()
existing_by_name: dict[str, Customer] = {}
for customer in existing_rows:
existing_by_name.setdefault(_clean_excel_text(customer.customer_name), customer)
imported = 0
updated = 0
for customer_name, payload in latest_by_name.items():
contact_name, contact_phone = serialize_contacts(payload["contacts"])
customer = existing_by_name.get(customer_name)
if customer:
customer.short_name = payload["short_name"] or customer.short_name or customer_name
customer.contact_name = contact_name
customer.contact_phone = contact_phone
customer.address = payload["address"] or None
if payload["credit_days"] is not None:
customer.credit_days = payload["credit_days"]
customer.status = "ACTIVE"
db.add(customer)
updated += 1
continue
customer = Customer(
customer_code=create_customer_code(db),
customer_name=customer_name,
short_name=payload["short_name"] or customer_name,
contact_name=contact_name,
contact_phone=contact_phone,
address=payload["address"] or None,
credit_days=payload["credit_days"] if payload["credit_days"] is not None else 30,
status="ACTIVE",
)
db.add(customer)
db.flush()
imported += 1
db.commit()
return {
"imported": imported,
"updated": updated,
"skipped": skipped,
"errors": errors,
"message": f"导入完成:新增 {imported} 条,更新 {updated} 条,跳过 {skipped} 条",
}
@router.get("/customers", response_model=list[CustomerRead])
def list_customers(
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
) -> list[CustomerRead]:
rows = db.scalars(select(Customer).order_by(Customer.id.desc()).limit(limit)).all()
return [_customer_read(row) for row in rows]
@router.get("/customers/export")
def export_customers(db: Session = Depends(get_db)) -> Response:
rows = db.scalars(select(Customer).order_by(Customer.customer_name.asc(), Customer.id.asc())).all()
workbook = _build_customer_workbook(rows)
return _excel_workbook_response(workbook, f"客户名录_{datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx", "customers.xlsx")
@router.post("/customers/import")
async def import_customers(
file: UploadFile = File(...),
db: Session = Depends(get_db),
) -> dict[str, object]:
content = await file.read()
try:
result = _import_customer_excel(db, content)
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result
@router.post("/customers", response_model=CustomerRead)
def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)) -> CustomerRead:
contact_name, contact_phone = _customer_contact_fields(payload)
customer = Customer(
customer_code=create_customer_code(db),
customer_name=payload.customer_name,
short_name=payload.short_name,
contact_name=contact_name,
contact_phone=contact_phone,
status="ACTIVE",
)
customer.address = payload.address
customer.tax_no = payload.tax_no
customer.credit_days = payload.credit_days
db.add(customer)
db.commit()
db.refresh(customer)
return _customer_read(customer)
@router.put("/customers/{customer_id}", response_model=CustomerRead)
def update_customer(customer_id: int, payload: CustomerCreate, db: Session = Depends(get_db)) -> CustomerRead:
customer = db.get(Customer, customer_id)
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
contact_name, contact_phone = _customer_contact_fields(payload)
customer.customer_name = payload.customer_name
customer.short_name = payload.short_name
customer.contact_name = contact_name
customer.contact_phone = contact_phone
customer.address = payload.address
customer.tax_no = payload.tax_no
customer.credit_days = payload.credit_days
customer.status = "ACTIVE"
db.add(customer)
db.commit()
db.refresh(customer)
return _customer_read(customer)
@router.get("/orders", response_model=list[SalesOrderRead])
def list_sales_orders(
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[SalesOrderRead]:
stmt = get_sales_order_query(limit=limit)
rows = db.execute(stmt).mappings().all()
return [SalesOrderRead.model_validate(dict(row)) for row in rows]
@router.post("/orders", response_model=SalesOrderCreateResult)
def create_sales_order(
payload: SalesOrderCreate,
db: Session = Depends(get_db),
context: AuthContext = Depends(require_authenticated_user),
) -> SalesOrderCreateResult:
customer = db.get(Customer, payload.customer_id)
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
if not payload.items:
raise HTTPException(status_code=400, detail="订单至少需要一条明细")
ensure_employee_has_permission(
db,
payload.sales_employee_id,
"MENU_SALES_PLANNING",
"销售人员",
required=True,
)
product_ids = [item.product_item_id for item in payload.items]
products = db.scalars(select(Item).where(Item.id.in_(product_ids))).all()
product_map = {product.id: product for product in products}
missing_products = [product_id for product_id in product_ids if product_id not in product_map]
if missing_products:
raise HTTPException(status_code=400, detail=f"产品不存在: {missing_products}")
order = SalesOrder(
order_no=build_sales_order_no(db, customer),
customer_id=payload.customer_id,
order_date=date.today(),
promised_date=payload.promised_date,
sales_employee_id=payload.sales_employee_id,
delivery_address=payload.delivery_address or customer.address,
tax_rate=decimalize(payload.tax_rate, "0.0001"),
total_amount=decimalize(0, "0.01"),
status="OPEN",
)
order.remark = payload.remark
db.add(order)
db.flush()
total_amount = decimalize(0, "0.01")
for idx, item_payload in enumerate(payload.items, start=1):
line_amount = decimalize(item_payload.order_qty * item_payload.unit_price, "0.01")
total_amount += line_amount
order_item = SalesOrderItem(
sales_order_id=order.id,
line_no=idx,
product_item_id=item_payload.product_item_id,
order_qty=decimalize(item_payload.order_qty),
delivered_qty=decimalize(0),
unit_price=decimalize(item_payload.unit_price, "0.0001"),
line_amount=line_amount,
promised_date=item_payload.promised_date or payload.promised_date,
status="OPEN",
)
order_item.customer_part_no = item_payload.customer_part_no
db.add(order_item)
order.total_amount = total_amount
db.commit()
try:
archive_kwargs = {}
created_by = _auth_context_user_id(context)
if created_by is not None:
archive_kwargs["created_by"] = created_by
archive_result = generate_document_archive(
db,
DOCUMENT_TYPE_SALES_ORDER,
int(order.id),
**archive_kwargs,
)
archive_status = archive_result.archive_status
archive_version = archive_result.archive_version
archive_error_message = archive_result.archive_error_message
except Exception as exc:
archive_status = "归档失败"
archive_version = None
archive_error_message = f"归档失败:{exc}"
return SalesOrderCreateResult(
order_id=order.id,
order_no=order.order_no,
total_amount=float(total_amount),
item_count=len(payload.items),
status=order.status,
archive_status=archive_status,
archive_version=archive_version,
archive_error_message=archive_error_message,
)
@router.delete("/orders/{order_id}")
def delete_sales_order(order_id: int, db: Session = Depends(get_db)) -> dict[str, str]:
ensure_purchase_order_sales_link_table(db)
order = db.get(SalesOrder, order_id)
if not order:
raise HTTPException(status_code=404, detail="销售订单不存在")
order_no = order.order_no
item_ids = list(db.scalars(select(SalesOrderItem.id).where(SalesOrderItem.sales_order_id == order_id)).all())
purchase_count = 0
work_order_count = 0
if item_ids:
purchase_count = db.scalar(
select(func.count(func.distinct(PurchaseOrderItem.purchase_order_id)))
.join(MaterialDemand, MaterialDemand.id == PurchaseOrderItem.source_demand_id)
.where(MaterialDemand.sales_order_item_id.in_(item_ids))
) or 0
work_order_count = db.scalar(select(func.count(WorkOrder.id)).where(WorkOrder.source_sales_order_item_id.in_(item_ids))) or 0
explicit_purchase_count = db.scalar(
select(func.count(PurchaseOrderSalesOrderLink.id))
.where(PurchaseOrderSalesOrderLink.sales_order_id == order_id)
) or 0
if purchase_count or explicit_purchase_count:
raise HTTPException(status_code=400, detail="销售订单已关联采购订单,不能删除")
delivery_count = db.scalar(select(func.count(Delivery.id)).where(Delivery.sales_order_id == order_id)) or 0
delivery_item_count = 0
if item_ids:
delivery_item_count = db.scalar(select(func.count(DeliveryItem.id)).where(DeliveryItem.sales_order_item_id.in_(item_ids))) or 0
if delivery_count or delivery_item_count:
raise HTTPException(status_code=400, detail="销售订单已有发货记录,不能删除")
return_count = db.scalar(select(func.count(ReturnOrder.id)).where(ReturnOrder.sales_order_id == order_id)) or 0
if return_count:
raise HTTPException(status_code=400, detail="销售订单已有退货记录,不能删除")
if work_order_count:
raise HTTPException(status_code=400, detail="销售订单已关联生产工单,不能删除")
if item_ids:
db.execute(delete(MaterialDemand).where(MaterialDemand.sales_order_item_id.in_(item_ids)))
db.execute(delete(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_id))
db.execute(delete(SalesOrder).where(SalesOrder.id == order_id))
db.commit()
return {"message": f"销售订单 {order_no} 已删除"}
@router.get("/order-items", response_model=list[SalesOrderItemRead])
def list_sales_order_items(
sales_order_id: int | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=1000),
db: Session = Depends(get_db),
) -> list[SalesOrderItemRead]:
stmt = get_sales_order_items_query(limit=limit, sales_order_id=sales_order_id)
rows = db.execute(stmt).mappings().all()
return [SalesOrderItemRead.model_validate(dict(row)) for row in rows]
@router.get("/deliveries", response_model=list[DeliveryRead])
def list_deliveries(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[DeliveryRead]:
rows = db.execute(get_deliveries_query(limit=limit)).mappings().all()
return [DeliveryRead.model_validate(dict(row)) for row in rows]
@router.get("/delivery-items", response_model=list[DeliveryItemRead])
def list_delivery_items(
delivery_id: int | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[DeliveryItemRead]:
rows = db.execute(get_delivery_items_query(limit=limit, delivery_id=delivery_id)).mappings().all()
return [DeliveryItemRead.model_validate(dict(row)) for row in rows]
@router.post("/deliveries", response_model=DeliveryRead)
def create_delivery(
payload: DeliveryCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> DeliveryRead:
warehouse = db.get(Warehouse, payload.warehouse_id)
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
if str(warehouse.warehouse_type or "").upper() != "FINISHED":
raise HTTPException(status_code=400, detail="销售出库必须选择成品库")
ensure_warehouses_unlocked(db, [payload.warehouse_id], "销售出库")
logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields(
payload.waybill_no,
payload.freight_amount,
required=True,
order_photo_url=payload.order_photo_url,
)
if not payload.items:
raise HTTPException(status_code=400, detail="发货单至少需要一条明细")
sales_order_id = payload.sales_order_id
linked_order_ids: set[int] = set()
for item_payload in payload.items:
if not item_payload.sales_order_item_id:
continue
linked_order_id = db.scalar(
select(SalesOrderItem.sales_order_id).where(SalesOrderItem.id == item_payload.sales_order_item_id)
)
if not linked_order_id:
raise HTTPException(status_code=400, detail="订单明细不存在")
linked_order_ids.add(int(linked_order_id))
if linked_order_ids:
if len(linked_order_ids) > 1:
raise HTTPException(status_code=400, detail="一张发货单只能关联同一个销售订单下的明细")
linked_order_id = next(iter(linked_order_ids))
if sales_order_id and int(sales_order_id) != linked_order_id:
raise HTTPException(status_code=400, detail="发货单关联的订单头与订单明细不一致")
sales_order_id = linked_order_id
sales_order = db.get(SalesOrder, sales_order_id) if sales_order_id else None
if sales_order_id and not sales_order:
raise HTTPException(status_code=404, detail="订单不存在")
customer_id = payload.customer_id or (sales_order.customer_id if sales_order else None)
customer = db.get(Customer, customer_id) if customer_id else None
if not customer:
raise HTTPException(status_code=404, detail="发货必须选择收货客户")
ensure_employee_has_permission(
db,
payload.shipper_employee_id,
"MENU_INVENTORY_LEDGER",
"发货人",
required=False,
)
delivery = Delivery(
delivery_no=build_delivery_no(db, sales_order=sales_order, customer=customer),
sales_order_id=sales_order_id,
customer_id=customer.id,
warehouse_id=payload.warehouse_id,
delivery_date=payload.delivery_date or datetime.now(),
shipper_employee_id=payload.shipper_employee_id,
consignee_name=payload.consignee_name or (customer.contact_name if customer else None),
consignee_phone=payload.consignee_phone or (customer.contact_phone if customer else None),
delivery_address=payload.delivery_address or (customer.address if customer else None) or (sales_order.delivery_address if sales_order else None),
logistics_waybill_no=logistics_waybill_no,
logistics_freight_amount=logistics_freight_amount,
logistics_photo_url=logistics_photo_url,
status="POSTED",
remark=payload.remark,
)
db.add(delivery)
db.flush()
inventory_txn_ids: list[int] = []
for index, item_payload in enumerate(payload.items, start=1):
order_item = db.get(SalesOrderItem, item_payload.sales_order_item_id) if item_payload.sales_order_item_id else None
if sales_order:
if not order_item or order_item.sales_order_id != sales_order_id:
raise HTTPException(status_code=400, detail="订单明细不存在或不属于当前订单")
if order_item.product_item_id != item_payload.product_item_id:
raise HTTPException(status_code=400, detail="发货产品与订单明细不一致")
product = db.get(Item, item_payload.product_item_id)
if not product:
raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}")
delivery_qty = to_decimal(item_payload.delivery_qty)
if delivery_qty <= 0:
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 的发货数量必须大于0")
delivery_weight = to_decimal(item_payload.delivery_weight_kg)
if order_item:
order_remaining_qty = to_decimal(order_item.order_qty) - to_decimal(order_item.delivered_qty)
if delivery_qty > order_remaining_qty:
raise HTTPException(
status_code=400,
detail=f"产品 {product.item_name} 的发货数量不能超过订单未发数量 {order_remaining_qty}",
)
lot = None
if item_payload.lot_id:
lot = db.get(StockLot, item_payload.lot_id)
if not lot:
raise HTTPException(status_code=404, detail="发货批次不存在")
if lot.item_id != item_payload.product_item_id or lot.warehouse_id != payload.warehouse_id:
raise HTTPException(status_code=400, detail="所选批次不属于当前产品或当前发货仓")
else:
lot = db.scalar(
select(StockLot).where(
StockLot.item_id == item_payload.product_item_id,
StockLot.warehouse_id == payload.warehouse_id,
StockLot.quality_status == "PASS",
StockLot.status == "AVAILABLE",
StockLot.remaining_qty > 0,
)
)
if not lot:
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 无可用批次库存")
if lot.quality_status != "PASS" or lot.status != "AVAILABLE":
raise HTTPException(status_code=400, detail=f"库存批次号 {lot.lot_no} 当前不可发货")
available_qty = to_decimal(lot.remaining_qty) - to_decimal(lot.locked_qty)
available_weight = to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg)
if delivery_qty > available_qty:
raise HTTPException(status_code=400, detail=f"库存批次号 {lot.lot_no} 可用数量不足")
if delivery_weight <= 0:
if available_qty > 0 and available_weight > 0:
delivery_weight = available_weight * (delivery_qty / available_qty)
else:
delivery_weight = delivery_qty * to_decimal(product.unit_weight_kg)
lot.remaining_qty = to_decimal(lot.remaining_qty) - delivery_qty
lot.remaining_weight_kg = to_decimal(lot.remaining_weight_kg) - delivery_weight
if to_decimal(lot.remaining_qty) <= 0 or to_decimal(lot.remaining_weight_kg) <= 0:
lot.status = "DEPLETED"
db.add(lot)
upsert_stock_balance(
db,
item_id=item_payload.product_item_id,
warehouse_id=payload.warehouse_id,
location_id=lot.location_id,
qty_delta=-delivery_qty,
weight_delta=-delivery_weight,
available_qty_delta=-delivery_qty,
available_weight_delta=-delivery_weight,
unit_cost=to_decimal(lot.unit_cost, "0.0001"),
)
txn = create_inventory_txn(
db,
txn_type="SALES_OUT",
item_id=item_payload.product_item_id,
warehouse_id=payload.warehouse_id,
location_id=lot.location_id,
lot_id=lot.id,
qty_change=-delivery_qty,
weight_change=-delivery_weight,
unit_cost=to_decimal(lot.unit_cost, "0.0001"),
source_doc_type="DELIVERY",
source_doc_id=delivery.id,
source_line_id=order_item.id if order_item else None,
biz_time=delivery.delivery_date,
operator_user_id=context.user.id,
remark="发货出库",
amount_basis="QTY",
logistics_waybill_no=logistics_waybill_no,
logistics_freight_amount=logistics_freight_amount,
logistics_photo_url=logistics_photo_url,
)
inventory_txn_ids.append(int(txn.id))
line_amount = delivery_qty * to_decimal(item_payload.unit_price, "0.0001")
db.add(
DeliveryItem(
delivery_id=delivery.id,
line_no=index,
sales_order_item_id=item_payload.sales_order_item_id if order_item else None,
product_item_id=item_payload.product_item_id,
lot_id=lot.id,
delivery_qty=delivery_qty,
delivery_weight_kg=delivery_weight,
unit_price=to_decimal(item_payload.unit_price, "0.0001"),
line_amount=to_decimal(line_amount, "0.01"),
status="POSTED",
remark=item_payload.remark,
)
)
if order_item:
order_item.delivered_qty = to_decimal(order_item.delivered_qty) + delivery_qty
if to_decimal(order_item.delivered_qty) >= to_decimal(order_item.order_qty):
order_item.status = "CLOSED"
else:
order_item.status = "PARTIAL"
db.add(order_item)
if sales_order_id:
sync_sales_order_status(db, sales_order_id)
db.commit()
archive_fields = _generate_delivery_archives_for_txns(db, inventory_txn_ids, created_by=context.user.id)
row = db.execute(get_deliveries_query(limit=1).where(Delivery.id == delivery.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="发货单创建后读取失败")
return _delivery_read_with_archive(dict(row), archive_fields)
+50
View File
@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from app.db.session import check_database_health, get_engine
from app.schemas.database import DataCountItem
from app.services.auth import require_authenticated_user
router = APIRouter()
@router.get("/health")
def health() -> dict[str, object]:
try:
db_status = check_database_health()
except (SQLAlchemyError, RuntimeError, Exception) as exc:
return {
"app": "ok",
"database": {
"connected": False,
"error": str(exc),
},
}
return {
"app": "ok",
"database": db_status,
}
@router.get("/data-summary", response_model=list[DataCountItem], dependencies=[Depends(require_authenticated_user)])
def data_summary() -> list[DataCountItem]:
summary_tables = [
("dept_count", "部门数量", "sys_department"),
("warehouse_count", "仓库数量", "wh_warehouse"),
("item_count", "物料数量", "md_item"),
("product_count", "产品数量", "md_product"),
("customer_count", "客户数量", "md_customer"),
("sales_order_count", "订单数量", "so_sales_order"),
("mrp_count", "MRP需求数量", "mrp_material_demand"),
("work_order_count", "工单数量", "pp_work_order"),
]
engine = get_engine()
result: list[DataCountItem] = []
with engine.connect() as conn:
for code, label, table_name in summary_tables:
count = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar() or 0
result.append(DataCountItem(code=code, label=label, count=int(count)))
return result
+359
View File
@@ -0,0 +1,359 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, desc, select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.miniapp import (
MiniAppAttendancePoint,
MiniAppNotice,
MiniAppNoticePoint,
MiniAppPersonRole,
MiniAppPersonnel,
)
from app.models.org import AiAssistantConfig, SystemConfig
from app.schemas.database import (
AiAssistantConfigRead,
AiAssistantConfigUpdate,
BroadcastMessageCreate,
BroadcastMessageRead,
SmartOperationReportConfigRead,
SmartOperationReportConfigUpdate,
SystemConfigRead,
SystemConfigUpdate,
)
from app.services.auth import AuthContext, require_authenticated_user, require_system_admin
from app.services.system_config import (
RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
RAW_MATERIAL_LOT_PREFIX_DEFAULT,
SMART_OPERATION_REPORT_CONFIG_CODE,
get_smart_operation_report_enabled,
normalize_enabled_config_value,
upsert_smart_operation_report_config,
upsert_system_config,
)
router = APIRouter()
def _active_attendance_point_names(db: Session) -> list[str]:
return [
name
for name in db.scalars(
select(MiniAppAttendancePoint.name)
.where(MiniAppAttendancePoint.is_active.is_(True))
.order_by(MiniAppAttendancePoint.name.asc())
).all()
if str(name or "").strip()
]
def _notice_point_names(db: Session, notice_id: int) -> list[str]:
return [
name
for name in db.scalars(
select(MiniAppNoticePoint.attendance_point_name)
.where(MiniAppNoticePoint.notice_id == notice_id)
.order_by(MiniAppNoticePoint.attendance_point_name.asc())
).all()
if str(name or "").strip()
]
def _replace_notice_points(db: Session, notice_id: int, point_names: list[str] | None) -> None:
selected_names = [str(name or "").strip() for name in point_names or _active_attendance_point_names(db) if str(name or "").strip()]
selected_names = list(dict.fromkeys(selected_names))
if selected_names:
existing_names = set(
db.scalars(select(MiniAppAttendancePoint.name).where(MiniAppAttendancePoint.name.in_(selected_names))).all()
)
missing = [name for name in selected_names if name not in existing_names]
if missing:
raise HTTPException(status_code=400, detail=f"小程序考勤点不存在:{'、'.join(missing)}")
db.execute(delete(MiniAppNoticePoint).where(MiniAppNoticePoint.notice_id == notice_id))
for name in selected_names:
db.add(MiniAppNoticePoint(notice_id=notice_id, attendance_point_name=name))
def _broadcast_read(db: Session, row: MiniAppNotice) -> BroadcastMessageRead:
return BroadcastMessageRead(
broadcast_id=row.id,
title=row.title,
content=row.content,
attendance_point_names=_notice_point_names(db, row.id),
tone="INFO",
status="ACTIVE" if row.is_active else "PAUSED",
starts_at=None,
ends_at=None,
sort_no=row.sort_order,
created_by=row.created_by,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _get_notice_creator_phone(db: Session) -> str:
admin_phone = db.scalar(
select(MiniAppPersonRole.phone)
.where(MiniAppPersonRole.role.in_(["admin", "manager"]))
.order_by(MiniAppPersonRole.role, MiniAppPersonRole.phone)
.limit(1)
)
if admin_phone:
return admin_phone
first_phone = db.scalar(select(MiniAppPersonnel.phone).order_by(MiniAppPersonnel.phone).limit(1))
if first_phone:
return first_phone
system_person = MiniAppPersonnel(
phone="ERP_SYSTEM",
name="ERP系统",
is_temporary=False,
temporary_expires_at=None,
)
db.add(system_person)
db.flush()
db.add(MiniAppPersonRole(phone=system_person.phone, role="manager"))
return system_person.phone
def _assistant_config_read(row: AiAssistantConfig | None) -> AiAssistantConfigRead:
if not row:
return AiAssistantConfigRead(
assistant_name="嘉恒工艺助手",
provider_name="",
api_base_url="",
api_key=None,
model_name="",
temperature=0.3,
system_prompt="你是宁波嘉恒智能五金 ERP 的企业助手,优先解释业务流程、字段含义、数据异常和操作步骤。",
enabled=False,
config_id=None,
api_key_configured=False,
updated_at=None,
)
return AiAssistantConfigRead(
config_id=row.id,
assistant_name=row.assistant_name,
provider_name=row.provider_name,
api_base_url=row.api_base_url,
api_key=None,
model_name=row.model_name,
temperature=float(row.temperature or 0.3),
system_prompt=row.system_prompt,
enabled=bool(row.enabled),
api_key_configured=bool(row.api_key),
updated_at=row.updated_at,
)
def _raw_material_lot_prefix_read(row: SystemConfig | None = None) -> SystemConfigRead:
if not row:
return SystemConfigRead(
config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
config_name="原材料库存批次号前缀",
config_value=RAW_MATERIAL_LOT_PREFIX_DEFAULT,
remark="原材料库存批次号默认前缀,例如 YL0001",
updated_at=None,
)
return SystemConfigRead.model_validate(row)
def _smart_operation_report_config_read(row: SystemConfig | None, db: Session) -> SmartOperationReportConfigRead:
enabled = get_smart_operation_report_enabled(db) if row is None else normalize_enabled_config_value(row.config_value) == "开启"
return SmartOperationReportConfigRead(
config_code=SMART_OPERATION_REPORT_CONFIG_CODE,
config_name="对接智能报工小程序",
enabled=enabled,
config_value="开启" if enabled else "关闭",
remark=row.remark if row else "开启后显示工序报工并按小程序报工数据计算生产进度;关闭后按ERP入库数据反推生产进度",
updated_at=row.updated_at if row else None,
)
def _active_broadcast_rows(db: Session, limit: int = 30) -> list[MiniAppNotice]:
return db.scalars(
select(MiniAppNotice)
.where(MiniAppNotice.is_active.is_(True))
.order_by(MiniAppNotice.sort_order.asc(), desc(MiniAppNotice.updated_at), desc(MiniAppNotice.id))
.limit(limit)
).all()
@router.get("/active-broadcast", response_model=BroadcastMessageRead | None)
def active_broadcast(
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> BroadcastMessageRead | None:
_ = context
rows = _active_broadcast_rows(db, limit=1)
row = rows[0] if rows else None
return _broadcast_read(db, row) if row else None
@router.get("/active-broadcasts", response_model=list[BroadcastMessageRead])
def active_broadcasts(
limit: int = Query(default=30, ge=1, le=100),
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> list[BroadcastMessageRead]:
_ = context
return [_broadcast_read(db, row) for row in _active_broadcast_rows(db, limit=limit)]
@router.get("/broadcasts", response_model=list[BroadcastMessageRead])
def list_broadcasts(
limit: int = Query(default=100, ge=1, le=300),
context: AuthContext = Depends(require_system_admin),
db: Session = Depends(get_db),
) -> list[BroadcastMessageRead]:
_ = context
rows = db.scalars(select(MiniAppNotice).order_by(MiniAppNotice.sort_order.asc(), desc(MiniAppNotice.updated_at)).limit(limit)).all()
return [_broadcast_read(db, row) for row in rows]
@router.post("/broadcasts", response_model=BroadcastMessageRead)
def create_broadcast(
payload: BroadcastMessageCreate,
context: AuthContext = Depends(require_system_admin),
db: Session = Depends(get_db),
) -> BroadcastMessageRead:
creator_phone = _get_notice_creator_phone(db)
row = MiniAppNotice(
title=payload.title,
content=payload.content,
sort_order=payload.sort_no,
is_active=payload.status == "ACTIVE",
created_by=creator_phone,
)
db.add(row)
db.flush()
_replace_notice_points(db, row.id, payload.attendance_point_names)
db.commit()
db.refresh(row)
return _broadcast_read(db, row)
@router.put("/broadcasts/{broadcast_id}", response_model=BroadcastMessageRead)
def update_broadcast(
broadcast_id: int,
payload: BroadcastMessageCreate,
context: AuthContext = Depends(require_system_admin),
db: Session = Depends(get_db),
) -> BroadcastMessageRead:
_ = context
row = db.get(MiniAppNotice, broadcast_id)
if not row:
raise HTTPException(status_code=404, detail="广播内容不存在")
row.title = payload.title
row.content = payload.content
row.sort_order = payload.sort_no
row.is_active = payload.status == "ACTIVE"
db.add(row)
_replace_notice_points(db, row.id, payload.attendance_point_names)
db.commit()
db.refresh(row)
return _broadcast_read(db, row)
@router.get("/assistant-config", response_model=AiAssistantConfigRead)
def get_assistant_config(
context: AuthContext = Depends(require_system_admin),
db: Session = Depends(get_db),
) -> AiAssistantConfigRead:
_ = context
row = db.scalars(select(AiAssistantConfig).order_by(desc(AiAssistantConfig.id)).limit(1)).first()
return _assistant_config_read(row)
@router.put("/assistant-config", response_model=AiAssistantConfigRead)
def save_assistant_config(
payload: AiAssistantConfigUpdate,
context: AuthContext = Depends(require_system_admin),
db: Session = Depends(get_db),
) -> AiAssistantConfigRead:
row = db.scalars(select(AiAssistantConfig).order_by(desc(AiAssistantConfig.id)).limit(1)).first()
if not row:
row = AiAssistantConfig(created_by=context.user.id)
row.assistant_name = payload.assistant_name
row.provider_name = payload.provider_name
row.api_base_url = payload.api_base_url
if payload.api_key is not None:
row.api_key = payload.api_key
row.model_name = payload.model_name
row.temperature = payload.temperature
row.system_prompt = payload.system_prompt
row.enabled = 1 if payload.enabled else 0
row.updated_by = context.user.id
db.add(row)
db.commit()
db.refresh(row)
return _assistant_config_read(row)
@router.get("/raw-material-lot-prefix", response_model=SystemConfigRead)
def get_raw_material_lot_prefix_config(
context: AuthContext = Depends(require_system_admin),
db: Session = Depends(get_db),
) -> SystemConfigRead:
_ = context
row = db.scalar(
select(SystemConfig).where(
SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
SystemConfig.status == "ACTIVE",
)
)
return _raw_material_lot_prefix_read(row)
@router.put("/raw-material-lot-prefix", response_model=SystemConfigRead)
def save_raw_material_lot_prefix_config(
payload: SystemConfigUpdate,
context: AuthContext = Depends(require_system_admin),
db: Session = Depends(get_db),
) -> SystemConfigRead:
row = upsert_system_config(
db,
config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
config_name="原材料库存批次号前缀",
config_value=payload.config_value,
remark=payload.remark or "原材料库存批次号默认前缀,例如 YL0001",
updated_by=context.user.id,
)
db.commit()
db.refresh(row)
return _raw_material_lot_prefix_read(row)
@router.get("/smart-operation-report", response_model=SmartOperationReportConfigRead)
def get_smart_operation_report_config(
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> SmartOperationReportConfigRead:
_ = context
row = db.scalar(
select(SystemConfig).where(
SystemConfig.config_code == SMART_OPERATION_REPORT_CONFIG_CODE,
SystemConfig.status == "ACTIVE",
)
)
return _smart_operation_report_config_read(row, db)
@router.put("/smart-operation-report", response_model=SmartOperationReportConfigRead)
def save_smart_operation_report_config(
payload: SmartOperationReportConfigUpdate,
context: AuthContext = Depends(require_system_admin),
db: Session = Depends(get_db),
) -> SmartOperationReportConfigRead:
row = upsert_smart_operation_report_config(
db,
enabled=payload.enabled,
updated_by=context.user.id,
remark=payload.remark,
)
db.commit()
db.refresh(row)
return _smart_operation_report_config_read(row, db)
@@ -0,0 +1,175 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.system_permissions import (
EmployeeOption,
MenuPermissionNode,
OrgEmployeeBind,
OrgNodeCreate,
OrgNodeRead,
OrgNodeUpdate,
PermissionRead,
RoleCreate,
RolePermissionUpdate,
RoleRead,
RoleUpdate,
SystemUserCreate,
SystemUserPasswordReset,
SystemUserRead,
SystemUserRoleUpdate,
SystemUserStatusUpdate,
)
from app.services.auth import AuthContext, require_system_admin
from app.services.system_permissions import (
build_org_tree,
bind_org_employee,
create_org_node,
create_role,
create_system_user,
delete_org_node,
list_employee_options,
list_menu_permission_tree,
list_permissions,
list_roles,
list_system_users,
reset_system_user_password,
unbind_org_employee,
update_org_node,
update_role,
update_system_user_roles,
update_system_user_status,
)
router = APIRouter(dependencies=[Depends(require_system_admin)])
@router.get("/org-tree", response_model=list[OrgNodeRead])
def get_org_tree(db: Session = Depends(get_db)) -> list[OrgNodeRead]:
return build_org_tree(db)
@router.post("/org-nodes", response_model=OrgNodeRead)
def post_org_node(payload: OrgNodeCreate, db: Session = Depends(get_db)) -> OrgNodeRead:
return create_org_node(db, payload)
@router.put("/org-nodes/{node_id}", response_model=OrgNodeRead)
def put_org_node(node_id: int, payload: OrgNodeUpdate, db: Session = Depends(get_db)) -> OrgNodeRead:
return update_org_node(db, node_id, payload)
@router.delete("/org-nodes/{node_id}", status_code=204)
def delete_org_node_route(node_id: int, db: Session = Depends(get_db)) -> None:
delete_org_node(db, node_id)
@router.post("/org-nodes/{node_id}/employees", response_model=OrgNodeRead)
def post_org_node_employee(
node_id: int,
payload: OrgEmployeeBind,
db: Session = Depends(get_db),
) -> OrgNodeRead:
return bind_org_employee(db, node_id, payload)
@router.delete("/org-nodes/{node_id}/employees/{employee_id}", response_model=OrgNodeRead)
def delete_org_node_employee(
node_id: int,
employee_id: int,
db: Session = Depends(get_db),
) -> OrgNodeRead:
return unbind_org_employee(db, node_id, employee_id)
@router.get("/permissions", response_model=list[PermissionRead])
def get_permissions(db: Session = Depends(get_db)) -> list[PermissionRead]:
return list_permissions(db)
@router.get("/menu-permission-tree", response_model=list[MenuPermissionNode])
def get_menu_permission_tree() -> list[MenuPermissionNode]:
return list_menu_permission_tree()
@router.get("/roles", response_model=list[RoleRead])
def get_roles(db: Session = Depends(get_db)) -> list[RoleRead]:
return list_roles(db)
@router.post("/roles", response_model=RoleRead)
def post_role(payload: RoleCreate, db: Session = Depends(get_db)) -> RoleRead:
return create_role(db, payload)
@router.put("/roles/{role_id}", response_model=RoleRead)
def put_role(role_id: int, payload: RoleUpdate, db: Session = Depends(get_db)) -> RoleRead:
return update_role(db, role_id, payload)
@router.put("/roles/{role_id}/permissions", response_model=RoleRead)
def put_role_permissions(
role_id: int,
payload: RolePermissionUpdate,
db: Session = Depends(get_db),
) -> RoleRead:
existing_role = next((role for role in list_roles(db) if role.role_id == role_id), None)
if not existing_role:
raise HTTPException(status_code=404, detail="角色不存在")
return update_role(
db,
role_id,
RoleUpdate(
role_name=existing_role.role_name,
role_scope=existing_role.role_scope,
status=existing_role.status,
remark=existing_role.remark,
permission_codes=payload.permission_codes,
),
)
@router.get("/employee-options", response_model=list[EmployeeOption])
def get_employee_options(
limit: int = Query(default=500, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[EmployeeOption]:
return list_employee_options(db, limit=limit)
@router.get("/users", response_model=list[SystemUserRead])
def get_system_users(db: Session = Depends(get_db)) -> list[SystemUserRead]:
return list_system_users(db)
@router.post("/users", response_model=SystemUserRead)
def post_system_user(payload: SystemUserCreate, db: Session = Depends(get_db)) -> SystemUserRead:
return create_system_user(db, payload)
@router.put("/users/{user_id}/roles", response_model=SystemUserRead)
def put_system_user_roles(
user_id: int,
payload: SystemUserRoleUpdate,
db: Session = Depends(get_db),
) -> SystemUserRead:
return update_system_user_roles(db, user_id, payload.role_ids)
@router.put("/users/{user_id}/status", response_model=SystemUserRead)
def put_system_user_status(
user_id: int,
payload: SystemUserStatusUpdate,
db: Session = Depends(get_db),
context: AuthContext = Depends(require_system_admin),
) -> SystemUserRead:
return update_system_user_status(db, user_id, payload.status, context.user.id)
@router.post("/users/{user_id}/reset-password", response_model=SystemUserRead)
def post_system_user_password_reset(
user_id: int,
payload: SystemUserPasswordReset,
db: Session = Depends(get_db),
) -> SystemUserRead:
return reset_system_user_password(db, user_id, payload.password)