Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
@@ -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)},
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user