Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import (
|
||||
auth,
|
||||
dashboard,
|
||||
document_archives,
|
||||
equipment,
|
||||
finance,
|
||||
inventory,
|
||||
master_data,
|
||||
planning,
|
||||
production,
|
||||
purchase,
|
||||
quality,
|
||||
returns,
|
||||
sales,
|
||||
system,
|
||||
system_extension,
|
||||
system_permissions,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(document_archives.router, prefix="/document-archives", tags=["document-archives"])
|
||||
api_router.include_router(system.router, prefix="/system", tags=["system"])
|
||||
api_router.include_router(system_extension.router, prefix="/system-extension", tags=["system-extension"])
|
||||
api_router.include_router(system_permissions.router, prefix="/system-permissions", tags=["system-permissions"])
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(master_data.router, prefix="/master-data", tags=["master-data"])
|
||||
api_router.include_router(sales.router, prefix="/sales", tags=["orders-customers"])
|
||||
api_router.include_router(planning.router, prefix="/planning", tags=["planning"])
|
||||
api_router.include_router(purchase.router, prefix="/purchase", tags=["purchase"])
|
||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||
api_router.include_router(production.router, prefix="/production", tags=["production"])
|
||||
api_router.include_router(equipment.router, prefix="/equipment", tags=["equipment"])
|
||||
api_router.include_router(quality.router, prefix="/quality", tags=["quality"])
|
||||
api_router.include_router(returns.router, prefix="/returns", tags=["returns"])
|
||||
api_router.include_router(finance.router, prefix="/finance", tags=["finance"])
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from sqlalchemy.engine import URL
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = Field(default="Jiaheng Hardware ERP API", alias="APP_NAME")
|
||||
app_env: str = Field(default="dev", alias="APP_ENV")
|
||||
app_host: str = Field(default="0.0.0.0", alias="APP_HOST")
|
||||
app_port: int = Field(default=8000, alias="APP_PORT")
|
||||
api_prefix: str = "/api"
|
||||
mysql_host: str = Field(default="127.0.0.1", alias="MYSQL_HOST")
|
||||
mysql_port: int = Field(default=3306, alias="MYSQL_PORT")
|
||||
mysql_database: str = Field(default="jiaheng_erp", alias="MYSQL_DATABASE")
|
||||
mysql_user: str = Field(default="root", alias="MYSQL_USER")
|
||||
mysql_password: str = Field(default="replace_me", alias="MYSQL_PASSWORD")
|
||||
allowed_origins: str = Field(default="http://localhost:5173", alias="ALLOWED_ORIGINS")
|
||||
auth_secret_key: str = Field(default="jiaheng-erp-dev-secret-change-me", alias="AUTH_SECRET_KEY")
|
||||
auth_token_expire_minutes: int = Field(default=480, alias="AUTH_TOKEN_EXPIRE_MINUTES")
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
@property
|
||||
def mysql_dsn(self) -> URL:
|
||||
return URL.create(
|
||||
"mysql+pymysql",
|
||||
username=self.mysql_user,
|
||||
password=self.mysql_password,
|
||||
host=self.mysql_host,
|
||||
port=self.mysql_port,
|
||||
database=self.mysql_database,
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_engine():
|
||||
settings = get_settings()
|
||||
return create_engine(
|
||||
settings.mysql_dsn,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(bind=get_engine(), autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
def get_db() -> Session:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def check_database_health() -> dict[str, object]:
|
||||
settings = get_settings()
|
||||
with get_engine().connect() as conn:
|
||||
current_db = conn.execute(text("SELECT DATABASE()")).scalar()
|
||||
version = conn.execute(text("SELECT VERSION()")).scalar()
|
||||
table_count = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = :schema_name
|
||||
"""
|
||||
),
|
||||
{"schema_name": settings.mysql_database},
|
||||
).scalar()
|
||||
return {
|
||||
"connected": True,
|
||||
"database": current_db,
|
||||
"version": version,
|
||||
"table_count": int(table_count or 0),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.docs import get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
description="面向五金加工企业的流程化、工序化、成本化 ERP API 服务",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["Content-Disposition"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
def custom_swagger_ui_html():
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=app.openapi_url,
|
||||
title=f"{app.title} - API 文档",
|
||||
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
||||
swagger_js_url="https://cdn.staticfile.net/swagger-ui/5.1.0/swagger-ui-bundle.js",
|
||||
swagger_css_url="https://cdn.staticfile.net/swagger-ui/5.1.0/swagger-ui.css",
|
||||
)
|
||||
|
||||
|
||||
@app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False)
|
||||
def swagger_ui_redirect():
|
||||
return get_swagger_ui_oauth2_redirect_html()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict[str, str]:
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"env": settings.app_env,
|
||||
"message": "Jiaheng Hardware ERP API is running.",
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Warehouse
|
||||
from app.models.document_archive import DocumentArchive
|
||||
from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole
|
||||
from app.models.planning import MaterialDemand
|
||||
from app.models.sales import Customer, SalesOrder, SalesOrderItem
|
||||
|
||||
__all__ = [
|
||||
"Customer",
|
||||
"Department",
|
||||
"DocumentArchive",
|
||||
"Employee",
|
||||
"Bom",
|
||||
"BomItem",
|
||||
"Item",
|
||||
"Material",
|
||||
"MaterialDemand",
|
||||
"Permission",
|
||||
"Product",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"SalesOrder",
|
||||
"SalesOrderItem",
|
||||
"StockBalance",
|
||||
"User",
|
||||
"UserRole",
|
||||
"Warehouse",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
@event.listens_for(Base, "before_insert", propagate=True)
|
||||
def set_audit_fields_before_insert(mapper, connection, target) -> None:
|
||||
_ = mapper, connection
|
||||
now = datetime.now()
|
||||
if hasattr(target, "created_at") and getattr(target, "created_at", None) is None:
|
||||
setattr(target, "created_at", now)
|
||||
if hasattr(target, "updated_at") and getattr(target, "updated_at", None) is None:
|
||||
setattr(target, "updated_at", now)
|
||||
|
||||
|
||||
@event.listens_for(Base, "before_update", propagate=True)
|
||||
def set_audit_fields_before_update(mapper, connection, target) -> None:
|
||||
_ = mapper, connection
|
||||
if hasattr(target, "updated_at"):
|
||||
setattr(target, "updated_at", datetime.now())
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class DocumentArchive(Base):
|
||||
__tablename__ = "document_archives"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"document_type",
|
||||
"business_id",
|
||||
"archive_version",
|
||||
"file_format",
|
||||
name="uk_document_archives_version",
|
||||
),
|
||||
Index("idx_document_archives_business_latest", "document_type", "business_id", "archive_version"),
|
||||
Index("idx_document_archives_document_no", "document_no"),
|
||||
Index("idx_document_archives_status", "status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
document_type: Mapped[str] = mapped_column(String(50))
|
||||
business_id: Mapped[int] = mapped_column(BigInteger)
|
||||
document_no: Mapped[str] = mapped_column(String(100))
|
||||
archive_version: Mapped[int] = mapped_column(Integer)
|
||||
template_version: Mapped[str] = mapped_column(String(50))
|
||||
file_format: Mapped[str] = mapped_column(String(20))
|
||||
file_name: Mapped[str] = mapped_column(String(255))
|
||||
file_path: Mapped[str] = mapped_column(String(500))
|
||||
file_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Date, DateTime, DECIMAL, ForeignKey, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Unit(Base):
|
||||
__tablename__ = "md_unit"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
unit_code: Mapped[str] = mapped_column(String(50))
|
||||
unit_name: Mapped[str] = mapped_column(String(50))
|
||||
precision_digits: Mapped[int] = mapped_column(Integer, server_default=text("3"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Item(Base):
|
||||
__tablename__ = "md_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
item_code: Mapped[str] = mapped_column(String(50))
|
||||
item_name: Mapped[str] = mapped_column(String(200))
|
||||
item_type: Mapped[str] = mapped_column(String(32))
|
||||
specification: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
material_grade: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
unit_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
sales_unit_id: Mapped[int | None] = mapped_column(ForeignKey("md_unit.id"), nullable=True)
|
||||
stock_unit_id: Mapped[int | None] = mapped_column(ForeignKey("md_unit.id"), nullable=True)
|
||||
purchase_unit_id: Mapped[int | None] = mapped_column(ForeignKey("md_unit.id"), nullable=True)
|
||||
safety_stock_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
scrap_sale_price: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Material(Base):
|
||||
__tablename__ = "md_material"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
default_supplier_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
purchase_calc_mode: Mapped[str] = mapped_column(String(32))
|
||||
min_purchase_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
purchase_multiple_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
purchase_multiple_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
lead_time_days: Mapped[int] = mapped_column(server_default=text("0"))
|
||||
quality_rule: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "md_product"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
drawing_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
product_family: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
version_no: Mapped[str] = mapped_column(String(50))
|
||||
standard_output_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
allowed_scrap_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0"))
|
||||
quality_standard: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Warehouse(Base):
|
||||
__tablename__ = "wh_warehouse"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
warehouse_code: Mapped[str] = mapped_column(String(50))
|
||||
warehouse_name: Mapped[str] = mapped_column(String(100))
|
||||
warehouse_type: Mapped[str] = mapped_column(String(32))
|
||||
manager_employee_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Bom(Base):
|
||||
__tablename__ = "md_bom"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
bom_code: Mapped[str] = mapped_column(String(50))
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
version_no: Mapped[str] = mapped_column(String(50))
|
||||
is_default: Mapped[int] = mapped_column(Integer)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
effective_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
expire_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class BomItem(Base):
|
||||
__tablename__ = "md_bom_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
bom_id: Mapped[int] = mapped_column(ForeignKey("md_bom.id"))
|
||||
seq_no: Mapped[int] = mapped_column(Integer)
|
||||
material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
usage_type: Mapped[str] = mapped_column(String(32))
|
||||
qty_per_piece: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
net_weight_per_piece_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
gross_weight_per_piece_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
loss_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0"))
|
||||
issue_over_tolerance_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0"))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class StockBalance(Base):
|
||||
__tablename__ = "wh_stock_balance"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
location_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
qty_on_hand: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
weight_on_hand_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
qty_available: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
weight_available_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
qty_allocated: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
weight_allocated_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
avg_unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, DECIMAL, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class MiniAppAttendancePoint(Base):
|
||||
__tablename__ = "attendance_points"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
latitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
|
||||
radius_meters: Mapped[int] = mapped_column(Integer, server_default=text("500"))
|
||||
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MiniAppPersonnel(Base):
|
||||
__tablename__ = "personnel"
|
||||
|
||||
phone: Mapped[str] = mapped_column(String(20), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
is_temporary: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
|
||||
temporary_expires_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MiniAppPersonRole(Base):
|
||||
__tablename__ = "person_roles"
|
||||
|
||||
phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), primary_key=True)
|
||||
role: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MiniAppPersonAttendancePoint(Base):
|
||||
__tablename__ = "person_attendance_points"
|
||||
|
||||
phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), primary_key=True)
|
||||
attendance_point_name: Mapped[str] = mapped_column(ForeignKey("attendance_points.name"), primary_key=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MiniAppProduct(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("'宁波嘉恒智能科技有限公司'"))
|
||||
project_no: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
product_name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
device_no: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
process_name: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
profile_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
material_code: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
material_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
supplier: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
product_net_weight_kg: Mapped[float | None] = mapped_column(DECIMAL(12, 4), nullable=True)
|
||||
product_gross_weight_kg: Mapped[float | None] = mapped_column(DECIMAL(12, 4), nullable=True)
|
||||
scrap_loss_rate: Mapped[float | None] = mapped_column(DECIMAL(10, 6), nullable=True)
|
||||
waste_price_yuan_per_kg: Mapped[float | None] = mapped_column(DECIMAL(12, 4), nullable=True)
|
||||
stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
operator_count: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("1"))
|
||||
standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0"))
|
||||
standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MiniAppProductionReport(Base):
|
||||
__tablename__ = "production_reports"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波嘉恒智能科技有限公司'"))
|
||||
session_id: Mapped[int] = mapped_column(BigInteger)
|
||||
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
|
||||
report_date: Mapped[object] = mapped_column(Date)
|
||||
start_at: Mapped[object] = mapped_column(DateTime)
|
||||
end_at: Mapped[object] = mapped_column(DateTime)
|
||||
duration_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0"))
|
||||
break_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0"))
|
||||
effective_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0"))
|
||||
total_good_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||
total_output_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||
actual_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0"))
|
||||
standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0"))
|
||||
expected_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||
pace_rate: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0"))
|
||||
workload_rate: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32), server_default=text("'pending'"))
|
||||
reviewer_phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
reviewed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
submitted_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MiniAppProductionReportItem(Base):
|
||||
__tablename__ = "production_report_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id"))
|
||||
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波嘉恒智能科技有限公司'"))
|
||||
device_no: Mapped[str] = mapped_column(String(64))
|
||||
project_no: Mapped[str] = mapped_column(String(64))
|
||||
product_name: Mapped[str] = mapped_column(String(255))
|
||||
material_code: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
material_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
raw_material_batch_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
process_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0"))
|
||||
standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||
good_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||
defect_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||
scrap_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||
allocated_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0"))
|
||||
started_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MiniAppNotice(Base):
|
||||
__tablename__ = "notices"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(120))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
||||
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MiniAppNoticePoint(Base):
|
||||
__tablename__ = "notice_points"
|
||||
|
||||
notice_id: Mapped[int] = mapped_column(ForeignKey("notices.id"), primary_key=True)
|
||||
attendance_point_name: Mapped[str] = mapped_column(ForeignKey("attendance_points.name"), primary_key=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,875 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, Date, DateTime, DECIMAL, ForeignKey, Integer, String, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Supplier(Base):
|
||||
__tablename__ = "md_supplier"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
supplier_code: Mapped[str] = mapped_column(String(50))
|
||||
supplier_name: Mapped[str] = mapped_column(String(200))
|
||||
short_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
contact_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
contact_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
lead_time_days: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
default_tax_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Process(Base):
|
||||
__tablename__ = "md_process"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
process_code: Mapped[str] = mapped_column(String(50))
|
||||
process_name: Mapped[str] = mapped_column(String(100))
|
||||
process_type: Mapped[str] = mapped_column(String(32))
|
||||
is_report_required: Mapped[int] = mapped_column(Integer, server_default=text("1"))
|
||||
is_quality_gate: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class WorkCenter(Base):
|
||||
__tablename__ = "md_work_center"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
center_code: Mapped[str] = mapped_column(String(50))
|
||||
center_name: Mapped[str] = mapped_column(String(100))
|
||||
dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id"))
|
||||
center_type: Mapped[str] = mapped_column(String(50))
|
||||
shift_pattern: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
capacity_hours_per_day: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class ProcessRoute(Base):
|
||||
__tablename__ = "md_process_route"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
route_code: Mapped[str] = mapped_column(String(50))
|
||||
route_name: Mapped[str] = mapped_column(String(100))
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
version_no: Mapped[str] = mapped_column(String(50))
|
||||
is_default: Mapped[int] = mapped_column(Integer, server_default=text("1"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
effective_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
expire_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class ProcessRouteOperation(Base):
|
||||
__tablename__ = "md_process_route_operation"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
route_id: Mapped[int] = mapped_column(ForeignKey("md_process_route.id"))
|
||||
seq_no: Mapped[int] = mapped_column(Integer)
|
||||
process_id: Mapped[int] = mapped_column(BigInteger)
|
||||
work_center_id: Mapped[int] = mapped_column(ForeignKey("md_work_center.id"))
|
||||
operation_name: Mapped[str] = mapped_column(String(100))
|
||||
std_setup_minutes: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
std_cycle_seconds: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
std_labor_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
std_machine_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
std_outsource_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
allowed_scrap_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0"))
|
||||
report_required: Mapped[int] = mapped_column(Integer, server_default=text("1"))
|
||||
is_key_operation: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Equipment(Base):
|
||||
__tablename__ = "em_equipment"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
equipment_code: Mapped[str] = mapped_column(String(50))
|
||||
equipment_name: Mapped[str] = mapped_column(String(100))
|
||||
category_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
work_center_id: Mapped[int] = mapped_column(ForeignKey("md_work_center.id"))
|
||||
manufacturer: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
model_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
install_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
rated_power_kw: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
occupied_area_sqm: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
maintenance_cycle_hours: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
last_maintenance_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
next_maintenance_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MaintenancePlan(Base):
|
||||
__tablename__ = "em_maintenance_plan"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
plan_code: Mapped[str] = mapped_column(String(50))
|
||||
equipment_id: Mapped[int] = mapped_column(ForeignKey("em_equipment.id"))
|
||||
plan_name: Mapped[str] = mapped_column(String(100))
|
||||
maintenance_type: Mapped[str] = mapped_column(String(32))
|
||||
cycle_hours: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
cycle_days: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
last_execute_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
next_execute_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class MaintenanceOrder(Base):
|
||||
__tablename__ = "em_maintenance_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
order_no: Mapped[str] = mapped_column(String(50))
|
||||
equipment_id: Mapped[int] = mapped_column(ForeignKey("em_equipment.id"))
|
||||
plan_id: Mapped[int | None] = mapped_column(ForeignKey("em_maintenance_plan.id"), nullable=True)
|
||||
maintenance_type: Mapped[str] = mapped_column(String(32))
|
||||
start_time: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
end_time: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
downtime_minutes: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
owner_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True)
|
||||
result_summary: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class PurchaseOrder(Base):
|
||||
__tablename__ = "po_purchase_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
po_no: Mapped[str] = mapped_column(String(50))
|
||||
supplier_id: Mapped[int] = mapped_column(ForeignKey("md_supplier.id"))
|
||||
order_date: Mapped[object] = mapped_column(Date)
|
||||
expected_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
warning_lead_days: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
purchaser_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True)
|
||||
target_warehouse_type: Mapped[str] = mapped_column(String(32), server_default=text("'RAW'"))
|
||||
tax_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0"))
|
||||
total_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class PurchaseOrderSalesOrderLink(Base):
|
||||
__tablename__ = "po_purchase_order_sales_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id"))
|
||||
sales_order_id: Mapped[int] = mapped_column(ForeignKey("so_sales_order.id"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class PurchaseOrderItem(Base):
|
||||
__tablename__ = "po_purchase_order_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id"))
|
||||
line_no: Mapped[int] = mapped_column(Integer)
|
||||
material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
source_demand_id: Mapped[int | None] = mapped_column(ForeignKey("mrp_material_demand.id"), nullable=True)
|
||||
order_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
order_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
received_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
received_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_price: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
line_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class WarehouseLocation(Base):
|
||||
__tablename__ = "wh_location"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
location_code: Mapped[str] = mapped_column(String(50))
|
||||
location_name: Mapped[str] = mapped_column(String(100))
|
||||
zone_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_default: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
is_locked: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class PurchaseReceipt(Base):
|
||||
__tablename__ = "po_receipt"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
receipt_no: Mapped[str] = mapped_column(String(50))
|
||||
purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id"))
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
receipt_date: Mapped[object] = mapped_column(DateTime)
|
||||
receiver_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True)
|
||||
supplier_delivery_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True)
|
||||
logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class PurchaseReceiptItem(Base):
|
||||
__tablename__ = "po_receipt_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
receipt_id: Mapped[int] = mapped_column(ForeignKey("po_receipt.id"))
|
||||
line_no: Mapped[int] = mapped_column(Integer)
|
||||
purchase_order_item_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order_item.id"))
|
||||
material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
|
||||
lot_no: Mapped[str] = mapped_column(String(50))
|
||||
merge_to_lot_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
received_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
received_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
accepted_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
accepted_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class PurchaseReturn(Base):
|
||||
__tablename__ = "po_purchase_return"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
return_no: Mapped[str] = mapped_column(String(50), unique=True)
|
||||
purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id"))
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
return_date: Mapped[object] = mapped_column(DateTime)
|
||||
operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||||
logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True)
|
||||
logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class PurchaseReturnItem(Base):
|
||||
__tablename__ = "po_purchase_return_item"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("purchase_return_id", "line_no", name="uk_po_purchase_return_item_line"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
purchase_return_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_return.id"))
|
||||
line_no: Mapped[int] = mapped_column(Integer)
|
||||
purchase_order_item_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order_item.id"))
|
||||
material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id"))
|
||||
return_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class StockLot(Base):
|
||||
__tablename__ = "wh_stock_lot"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
lot_no: Mapped[str] = mapped_column(String(50))
|
||||
parent_lot_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
lot_role: Mapped[str] = mapped_column(String(32), server_default=text("'INVENTORY'"))
|
||||
material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
|
||||
source_doc_type: Mapped[str] = mapped_column(String(32))
|
||||
source_doc_id: Mapped[int] = mapped_column(BigInteger)
|
||||
source_line_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
source_material_lot_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
source_material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
source_material_summary: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True)
|
||||
logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
inbound_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
inbound_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
remaining_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
remaining_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
locked_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
locked_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
production_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
expire_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
quality_status: Mapped[str] = mapped_column(String(32))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class InventoryTxn(Base):
|
||||
__tablename__ = "wh_inventory_txn"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
txn_no: Mapped[str] = mapped_column(String(50))
|
||||
txn_type: Mapped[str] = mapped_column(String(32))
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
|
||||
lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True)
|
||||
qty_change: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
weight_change_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
source_doc_type: Mapped[str] = mapped_column(String(32))
|
||||
source_doc_id: Mapped[int] = mapped_column(BigInteger)
|
||||
source_line_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True)
|
||||
logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
biz_time: Mapped[object] = mapped_column(DateTime)
|
||||
operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class SpecialWarehouseAdjustment(Base):
|
||||
__tablename__ = "wh_special_adjustment"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
adjustment_no: Mapped[str] = mapped_column(String(50), unique=True)
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
warehouse_type: Mapped[str] = mapped_column(String(32))
|
||||
direction: Mapped[str] = mapped_column(String(32))
|
||||
reason: Mapped[str] = mapped_column(String(1000))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||||
adjusted_at: Mapped[object] = mapped_column(DateTime)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class SpecialWarehouseAdjustmentLine(Base):
|
||||
__tablename__ = "wh_special_adjustment_line"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
adjustment_id: Mapped[int] = mapped_column(ForeignKey("wh_special_adjustment.id"))
|
||||
line_no: Mapped[int] = mapped_column(Integer)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
|
||||
lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id"))
|
||||
inventory_txn_id: Mapped[int | None] = mapped_column(ForeignKey("wh_inventory_txn.id"), nullable=True)
|
||||
before_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
change_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
after_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
before_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
change_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
after_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
line_reason: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Stocktake(Base):
|
||||
__tablename__ = "wh_stocktake"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
stocktake_no: Mapped[str] = mapped_column(String(50))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
started_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
imported_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
confirmed_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
canceled_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
started_at: Mapped[object] = mapped_column(DateTime)
|
||||
exported_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
imported_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
confirmed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
canceled_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class StocktakeWarehouse(Base):
|
||||
__tablename__ = "wh_stocktake_warehouse"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id"))
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
warehouse_type: Mapped[str] = mapped_column(String(32))
|
||||
warehouse_name: Mapped[str] = mapped_column(String(100))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
created_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class StocktakeLine(Base):
|
||||
__tablename__ = "wh_stocktake_line"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id"))
|
||||
stocktake_warehouse_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stocktake_warehouse.id"), nullable=True)
|
||||
line_no: Mapped[int] = mapped_column(Integer)
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
warehouse_name: Mapped[str] = mapped_column(String(100))
|
||||
warehouse_type: Mapped[str] = mapped_column(String(32))
|
||||
location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
|
||||
location_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
item_code: Mapped[str] = mapped_column(String(50))
|
||||
item_name: Mapped[str] = mapped_column(String(200))
|
||||
item_type: Mapped[str] = mapped_column(String(32))
|
||||
lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True)
|
||||
lot_no: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
source_material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
snapshot_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
snapshot_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
snapshot_unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
counted_qty: Mapped[float | None] = mapped_column(DECIMAL(18, 6), nullable=True)
|
||||
counted_weight_kg: Mapped[float | None] = mapped_column(DECIMAL(18, 6), nullable=True)
|
||||
diff_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
diff_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
diff_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
diff_type: Mapped[str] = mapped_column(String(32))
|
||||
row_status: Mapped[str] = mapped_column(String(32))
|
||||
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class StocktakeAdjustment(Base):
|
||||
__tablename__ = "wh_stocktake_adjustment"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id"))
|
||||
stocktake_line_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake_line.id"))
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True)
|
||||
adjustment_lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True)
|
||||
inventory_txn_id: Mapped[int | None] = mapped_column(ForeignKey("wh_inventory_txn.id"), nullable=True)
|
||||
adjustment_type: Mapped[str] = mapped_column(String(32))
|
||||
before_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
after_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
before_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
after_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
qty_change: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
weight_change_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
updated_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class WorkOrder(Base):
|
||||
__tablename__ = "pp_work_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
work_order_no: Mapped[str] = mapped_column(String(80))
|
||||
work_order_type: Mapped[str] = mapped_column(String(32))
|
||||
source_sales_order_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order_item.id"), nullable=True)
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
route_id: Mapped[int] = mapped_column(ForeignKey("md_process_route.id"))
|
||||
bom_id: Mapped[int] = mapped_column(ForeignKey("md_bom.id"))
|
||||
planned_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
released_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
finished_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
scrap_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
planned_start_time: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
planned_end_time: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
actual_start_time: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
actual_end_time: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
priority_level: Mapped[int] = mapped_column(Integer, server_default=text("3"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class WorkOrderMaterial(Base):
|
||||
__tablename__ = "pp_work_order_material"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id"))
|
||||
bom_item_id: Mapped[int] = mapped_column(ForeignKey("md_bom_item.id"))
|
||||
material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
required_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
required_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
issued_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
issued_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
returned_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
returned_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class WorkOrderMaterialIssue(Base):
|
||||
__tablename__ = "pp_work_order_material_issue"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
work_order_material_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order_material.id"))
|
||||
work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id"))
|
||||
material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
source_lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id"))
|
||||
material_sub_batch_no: Mapped[str] = mapped_column(String(80))
|
||||
issued_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
issued_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
issue_time: Mapped[object] = mapped_column(DateTime)
|
||||
operator_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class ProductionBatchLedger(Base):
|
||||
__tablename__ = "pp_production_batch_ledger"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("material_lot_id", "product_item_id", name="uk_production_batch_ledger_lot_product"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
material_lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id"))
|
||||
material_lot_no: Mapped[str] = mapped_column(String(128))
|
||||
material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
status: Mapped[str] = mapped_column(String(32), server_default=text("'在生产'"))
|
||||
miniapp_selectable: Mapped[int] = mapped_column(Integer, server_default=text("1"))
|
||||
total_issued_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
outside_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
finished_inbound_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
surplus_inbound_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
scrap_inbound_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
writeoff_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
first_issue_time: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
last_issue_time: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
locked_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
reopened_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
lock_count: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class ProductionBatchLedgerTxn(Base):
|
||||
__tablename__ = "pp_production_batch_ledger_txn"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
production_ledger_id: Mapped[int] = mapped_column(ForeignKey("pp_production_batch_ledger.id"))
|
||||
txn_type: Mapped[str] = mapped_column(String(32))
|
||||
qty_delta: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
weight_delta_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
outside_weight_after_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
source_doc_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
source_doc_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
source_line_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
biz_time: Mapped[object] = mapped_column(DateTime)
|
||||
operator_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class WorkOrderOperation(Base):
|
||||
__tablename__ = "pp_work_order_operation"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id"))
|
||||
route_operation_id: Mapped[int] = mapped_column(ForeignKey("md_process_route_operation.id"))
|
||||
seq_no: Mapped[int] = mapped_column(Integer)
|
||||
operation_name: Mapped[str] = mapped_column(String(100))
|
||||
work_center_id: Mapped[int] = mapped_column(ForeignKey("md_work_center.id"))
|
||||
planned_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
report_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
good_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
scrap_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
rework_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
std_labor_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
std_machine_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
std_outsource_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
allowed_scrap_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0"))
|
||||
is_abnormal: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class OperationReport(Base):
|
||||
__tablename__ = "pp_operation_report"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
report_no: Mapped[str] = mapped_column(String(50))
|
||||
work_order_id: Mapped[int | None] = mapped_column(ForeignKey("pp_work_order.id"), nullable=True)
|
||||
work_order_operation_id: Mapped[int | None] = mapped_column(ForeignKey("pp_work_order_operation.id"), nullable=True)
|
||||
production_ledger_id: Mapped[int | None] = mapped_column(ForeignKey("pp_production_batch_ledger.id"), nullable=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("hr_employee.id"))
|
||||
equipment_id: Mapped[int | None] = mapped_column(ForeignKey("em_equipment.id"), nullable=True)
|
||||
report_source: Mapped[str] = mapped_column(String(32))
|
||||
shift_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
start_time: Mapped[object] = mapped_column(DateTime)
|
||||
end_time: Mapped[object] = mapped_column(DateTime)
|
||||
report_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
good_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
scrap_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
rework_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
scrap_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
downtime_minutes: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
extra_cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
is_abnormal: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
approved_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||||
approved_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class ScrapRecord(Base):
|
||||
__tablename__ = "pp_scrap_record"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
scrap_no: Mapped[str] = mapped_column(String(50))
|
||||
report_id: Mapped[int] = mapped_column(ForeignKey("pp_operation_report.id"))
|
||||
work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id"))
|
||||
work_order_operation_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order_operation.id"))
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
scrap_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
scrap_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
scrap_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
disposal_type: Mapped[str] = mapped_column(String(32))
|
||||
saleable_scrap_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
scrap_cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class CompletionReceipt(Base):
|
||||
__tablename__ = "pp_completion_receipt"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
receipt_no: Mapped[str] = mapped_column(String(50))
|
||||
work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id"))
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
receipt_time: Mapped[object] = mapped_column(DateTime)
|
||||
receiver_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class CompletionReceiptItem(Base):
|
||||
__tablename__ = "pp_completion_receipt_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
completion_receipt_id: Mapped[int] = mapped_column(ForeignKey("pp_completion_receipt.id"))
|
||||
line_no: Mapped[int] = mapped_column(Integer)
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
|
||||
lot_no: Mapped[str] = mapped_column(String(50))
|
||||
source_material_lot_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
source_material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
source_material_summary: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
receipt_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
receipt_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Delivery(Base):
|
||||
__tablename__ = "so_delivery"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
delivery_no: Mapped[str] = mapped_column(String(50))
|
||||
sales_order_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order.id"), nullable=True)
|
||||
customer_id: Mapped[int | None] = mapped_column(ForeignKey("md_customer.id"), nullable=True)
|
||||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||||
delivery_date: Mapped[object] = mapped_column(DateTime)
|
||||
shipper_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True)
|
||||
consignee_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
consignee_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
delivery_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True)
|
||||
logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class DeliveryItem(Base):
|
||||
__tablename__ = "so_delivery_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
delivery_id: Mapped[int] = mapped_column(ForeignKey("so_delivery.id"))
|
||||
line_no: Mapped[int] = mapped_column(Integer)
|
||||
sales_order_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order_item.id"), nullable=True)
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True)
|
||||
delivery_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
delivery_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_price: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
line_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class ReturnOrder(Base):
|
||||
__tablename__ = "rt_return_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
return_no: Mapped[str] = mapped_column(String(50))
|
||||
customer_id: Mapped[int] = mapped_column(ForeignKey("md_customer.id"))
|
||||
sales_order_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order.id"), nullable=True)
|
||||
delivery_id: Mapped[int | None] = mapped_column(ForeignKey("so_delivery.id"), nullable=True)
|
||||
return_date: Mapped[object] = mapped_column(DateTime)
|
||||
return_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
handler_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class ReturnItem(Base):
|
||||
__tablename__ = "rt_return_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
return_order_id: Mapped[int] = mapped_column(ForeignKey("rt_return_order.id"))
|
||||
line_no: Mapped[int] = mapped_column(Integer)
|
||||
sales_order_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order_item.id"), nullable=True)
|
||||
delivery_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_delivery_item.id"), nullable=True)
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
source_lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True)
|
||||
return_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
return_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
disposition_status: Mapped[str] = mapped_column(String(32))
|
||||
responsibility_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class ReturnDisposition(Base):
|
||||
__tablename__ = "rt_return_disposition"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
disposition_no: Mapped[str] = mapped_column(String(50))
|
||||
return_item_id: Mapped[int] = mapped_column(ForeignKey("rt_return_item.id"))
|
||||
disposition_type: Mapped[str] = mapped_column(String(32))
|
||||
rework_work_order_id: Mapped[int | None] = mapped_column(ForeignKey("pp_work_order.id"), nullable=True)
|
||||
scrap_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
extra_cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
scrap_sale_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
closed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class AccountingPeriod(Base):
|
||||
__tablename__ = "fi_accounting_period"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
period_code: Mapped[str] = mapped_column(String(20))
|
||||
year_no: Mapped[int] = mapped_column(Integer)
|
||||
month_no: Mapped[int] = mapped_column(Integer)
|
||||
start_date: Mapped[object] = mapped_column(Date)
|
||||
end_date: Mapped[object] = mapped_column(Date)
|
||||
close_status: Mapped[str] = mapped_column(String(32))
|
||||
closed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class OverheadEntry(Base):
|
||||
__tablename__ = "fi_overhead_entry"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
period_id: Mapped[int] = mapped_column(ForeignKey("fi_accounting_period.id"))
|
||||
overhead_type: Mapped[str] = mapped_column(String(32))
|
||||
dept_id: Mapped[int | None] = mapped_column(ForeignKey("sys_department.id"), nullable=True)
|
||||
amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
allocation_basis: Mapped[str] = mapped_column(String(32))
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class CostAllocation(Base):
|
||||
__tablename__ = "fi_cost_allocation"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
period_id: Mapped[int] = mapped_column(ForeignKey("fi_accounting_period.id"))
|
||||
work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id"))
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
allocation_basis_value: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
overhead_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
material_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
labor_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
machine_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
abnormal_loss_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
total_cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class StatementSnapshot(Base):
|
||||
__tablename__ = "fi_statement_snapshot"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
snapshot_no: Mapped[str] = mapped_column(String(50))
|
||||
period_id: Mapped[int] = mapped_column(ForeignKey("fi_accounting_period.id"))
|
||||
statement_type: Mapped[str] = mapped_column(String(32))
|
||||
report_date: Mapped[object] = mapped_column(Date)
|
||||
summary_json: Mapped[dict] = mapped_column(JSON)
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import DECIMAL, BigInteger, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Department(Base):
|
||||
__tablename__ = "sys_department"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
dept_code: Mapped[str] = mapped_column(String(50))
|
||||
dept_name: Mapped[str] = mapped_column(String(100))
|
||||
parent_id: Mapped[int | None] = mapped_column(ForeignKey("sys_department.id"), nullable=True)
|
||||
org_node_type: Mapped[str] = mapped_column(String(32), server_default=text("'DEPARTMENT'"))
|
||||
dept_type: Mapped[str] = mapped_column(String(32))
|
||||
manager_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
manager_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Employee(Base):
|
||||
__tablename__ = "hr_employee"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
employee_code: Mapped[str] = mapped_column(String(50))
|
||||
employee_name: Mapped[str] = mapped_column(String(100))
|
||||
dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id"))
|
||||
mobile: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
gender: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
hire_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
job_title: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
shift_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
manager_employee_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
is_operator: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
is_workshop_staff: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "sys_role"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
role_code: Mapped[str] = mapped_column(String(50))
|
||||
role_name: Mapped[str] = mapped_column(String(100))
|
||||
role_scope: Mapped[str] = mapped_column(String(32))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "sys_permission"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
permission_code: Mapped[str] = mapped_column(String(100))
|
||||
permission_name: Mapped[str] = mapped_column(String(100))
|
||||
module_code: Mapped[str] = mapped_column(String(50))
|
||||
action_code: Mapped[str] = mapped_column(String(50))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
__tablename__ = "sys_role_permission"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
role_id: Mapped[int] = mapped_column(ForeignKey("sys_role.id"))
|
||||
permission_id: Mapped[int] = mapped_column(ForeignKey("sys_permission.id"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "sys_user"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(50))
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("hr_employee.id"))
|
||||
dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id"))
|
||||
nickname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_super_admin: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
last_login_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
__tablename__ = "sys_user_role"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"))
|
||||
role_id: Mapped[int] = mapped_column(ForeignKey("sys_role.id"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class OrgEmployeeBinding(Base):
|
||||
__tablename__ = "sys_org_employee_binding"
|
||||
__table_args__ = (UniqueConstraint("dept_id", "employee_id", name="uk_org_employee_binding"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id"))
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("hr_employee.id"))
|
||||
sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class OrgManagerBinding(Base):
|
||||
__tablename__ = "sys_org_manager_binding"
|
||||
__table_args__ = (UniqueConstraint("dept_id", "employee_id", name="uk_org_manager_binding"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id"))
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("hr_employee.id"))
|
||||
sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'"))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class BroadcastMessage(Base):
|
||||
__tablename__ = "sys_broadcast_message"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(100))
|
||||
content: Mapped[str] = mapped_column(String(500))
|
||||
tone: Mapped[str] = mapped_column(String(32), server_default=text("'INFO'"))
|
||||
status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'"))
|
||||
starts_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
ends_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "sys_system_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
config_code: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
config_name: Mapped[str] = mapped_column(String(100))
|
||||
config_value: Mapped[str] = mapped_column(String(500))
|
||||
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'"))
|
||||
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class AiAssistantConfig(Base):
|
||||
__tablename__ = "sys_ai_assistant_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
assistant_name: Mapped[str] = mapped_column(String(100), server_default=text("'嘉恒工艺助手'"))
|
||||
provider_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
api_base_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
api_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
temperature: Mapped[float] = mapped_column(DECIMAL(6, 3), server_default=text("0.300"))
|
||||
system_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
enabled: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||||
updated_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, DECIMAL, ForeignKey, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class MaterialDemand(Base):
|
||||
__tablename__ = "mrp_material_demand"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
demand_no: Mapped[str] = mapped_column(String(50))
|
||||
sales_order_item_id: Mapped[int] = mapped_column(ForeignKey("so_sales_order_item.id"))
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
bom_item_id: Mapped[int] = mapped_column(ForeignKey("md_bom_item.id"))
|
||||
order_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
theoretical_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
planned_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
available_stock_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
shortage_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
suggested_purchase_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
suggested_purchase_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
generated_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Date, DateTime, DECIMAL, ForeignKey, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "md_customer"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
customer_code: Mapped[str] = mapped_column(String(50))
|
||||
customer_name: Mapped[str] = mapped_column(String(200))
|
||||
short_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
contact_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
contact_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
tax_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
credit_days: Mapped[int] = mapped_column(server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class SalesOrder(Base):
|
||||
__tablename__ = "so_sales_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
order_no: Mapped[str] = mapped_column(String(50))
|
||||
customer_id: Mapped[int] = mapped_column(ForeignKey("md_customer.id"))
|
||||
order_date: Mapped[object] = mapped_column(Date)
|
||||
promised_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
sales_employee_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
delivery_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
tax_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0"))
|
||||
total_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class SalesOrderItem(Base):
|
||||
__tablename__ = "so_sales_order_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
sales_order_id: Mapped[int] = mapped_column(ForeignKey("so_sales_order.id"))
|
||||
line_no: Mapped[int] = mapped_column(Integer)
|
||||
product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||||
customer_part_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
order_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
delivered_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||||
unit_price: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||||
line_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
|
||||
promised_date: Mapped[object | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
created_at: Mapped[object] = mapped_column(DateTime)
|
||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OverviewMetric(BaseModel):
|
||||
code: str
|
||||
label: str
|
||||
value: str
|
||||
unit: str | None = None
|
||||
highlight: str = "normal"
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class AlertItem(BaseModel):
|
||||
level: str
|
||||
title: str
|
||||
description: str
|
||||
owner: str
|
||||
action: str
|
||||
|
||||
|
||||
class FlowStep(BaseModel):
|
||||
step_code: str
|
||||
title: str
|
||||
status: str
|
||||
summary: str
|
||||
quantity: str
|
||||
|
||||
|
||||
class BoardSection(BaseModel):
|
||||
title: str
|
||||
items: list[FlowStep]
|
||||
|
||||
|
||||
class DashboardPayload(BaseModel):
|
||||
company: str
|
||||
overview: list[OverviewMetric]
|
||||
alerts: list[AlertItem]
|
||||
flow_board: list[BoardSection]
|
||||
|
||||
|
||||
class DashboardSalesOrderRead(BaseModel):
|
||||
order_id: int
|
||||
order_no: str
|
||||
customer_code: str | None = None
|
||||
customer_name: str
|
||||
order_date: date
|
||||
promised_date: date | None = None
|
||||
delivery_address: str | None = None
|
||||
total_amount: float
|
||||
status: str
|
||||
line_count: int
|
||||
total_order_qty: float
|
||||
total_delivered_qty: float
|
||||
mrp_count: int
|
||||
purchase_order_count: int
|
||||
work_order_count: int
|
||||
completion_receipt_count: int
|
||||
delivery_count: int
|
||||
return_count: int
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class DashboardMaterialLotRead(BaseModel):
|
||||
lot_id: int
|
||||
lot_no: str
|
||||
material_item_id: int
|
||||
material_code: str
|
||||
material_name: str
|
||||
specification: str | None = None
|
||||
warehouse_name: str
|
||||
inbound_weight_kg: float
|
||||
remaining_weight_kg: float
|
||||
unit_cost: float
|
||||
inbound_amount: float
|
||||
issued_batch_count: int
|
||||
issued_weight_kg: float
|
||||
finished_batch_count: int
|
||||
finished_qty: float
|
||||
delivery_count: int
|
||||
status: str
|
||||
quality_status: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class LifecycleDetailItem(BaseModel):
|
||||
label: str
|
||||
value: str
|
||||
|
||||
|
||||
class LifecycleNode(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
status: str | None = None
|
||||
stage: str
|
||||
level: int
|
||||
tone: str = "normal"
|
||||
summary: str | None = None
|
||||
metrics: list[LifecycleDetailItem] = Field(default_factory=list)
|
||||
details: list[LifecycleDetailItem] = Field(default_factory=list)
|
||||
raw: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class LifecycleEdge(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class SalesOrderLifecycleRead(BaseModel):
|
||||
order: DashboardSalesOrderRead
|
||||
nodes: list[LifecycleNode]
|
||||
edges: list[LifecycleEdge]
|
||||
|
||||
|
||||
class MaterialLotLifecycleRead(BaseModel):
|
||||
lot: DashboardMaterialLotRead
|
||||
nodes: list[LifecycleNode]
|
||||
edges: list[LifecycleEdge]
|
||||
@@ -0,0 +1,644 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class DataCountItem(BaseModel):
|
||||
code: str
|
||||
label: str
|
||||
count: int
|
||||
|
||||
|
||||
class BroadcastMessageCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=100)
|
||||
content: str = Field(min_length=1, max_length=500)
|
||||
attendance_point_names: list[str] = Field(default_factory=list)
|
||||
tone: str = "INFO"
|
||||
status: str = "ACTIVE"
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
sort_no: int = 0
|
||||
|
||||
|
||||
class BroadcastMessageRead(BroadcastMessageCreate):
|
||||
broadcast_id: int
|
||||
created_by: int | str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AiAssistantConfigUpdate(BaseModel):
|
||||
assistant_name: str = Field(default="嘉恒工艺助手", min_length=1, max_length=100)
|
||||
provider_name: str | None = None
|
||||
api_base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
model_name: str | None = None
|
||||
temperature: float = Field(default=0.3, ge=0, le=2)
|
||||
system_prompt: str | None = None
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class AiAssistantConfigRead(AiAssistantConfigUpdate):
|
||||
config_id: int | None = None
|
||||
api_key_configured: bool = False
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class SystemConfigUpdate(BaseModel):
|
||||
config_value: str = Field(min_length=1, max_length=500)
|
||||
remark: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class SystemConfigRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
config_code: str
|
||||
config_name: str
|
||||
config_value: str
|
||||
remark: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class SmartOperationReportConfigUpdate(BaseModel):
|
||||
enabled: bool
|
||||
remark: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class SmartOperationReportConfigRead(BaseModel):
|
||||
config_code: str = "SMART_OPERATION_REPORT_ENABLED"
|
||||
config_name: str = "对接智能报工小程序"
|
||||
enabled: bool = True
|
||||
config_value: str = "开启"
|
||||
remark: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class LoginUser(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
employee_id: int
|
||||
mobile: str | None = None
|
||||
display_name: str
|
||||
title: str
|
||||
dept_name: str | None = None
|
||||
role_codes: list[str]
|
||||
role_names: list[str]
|
||||
permission_codes: list[str]
|
||||
|
||||
|
||||
class AuthLoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class AuthChangePasswordRequest(BaseModel):
|
||||
old_password: str = Field(min_length=1)
|
||||
new_password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class AuthResetPasswordRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=50)
|
||||
new_password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class AuthSession(LoginUser):
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class DepartmentRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
dept_code: str
|
||||
dept_name: str
|
||||
dept_type: str
|
||||
manager_name: str | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class EmployeeRead(BaseModel):
|
||||
id: int
|
||||
employee_code: str
|
||||
employee_name: str
|
||||
dept_id: int
|
||||
dept_name: str | None = None
|
||||
mobile: str | None = None
|
||||
gender: str | None = None
|
||||
hire_date: date | None = None
|
||||
job_title: str | None = None
|
||||
shift_code: str | None = None
|
||||
is_operator: bool
|
||||
is_workshop_staff: bool
|
||||
status: str
|
||||
remark: str | None = None
|
||||
username: str | None = None
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployeeCreate(BaseModel):
|
||||
employee_name: str
|
||||
dept_id: int = Field(gt=0)
|
||||
mobile: str | None = None
|
||||
gender: str | None = None
|
||||
hire_date: date | None = None
|
||||
job_title: str | None = None
|
||||
shift_code: str | None = None
|
||||
manager_employee_id: int | None = None
|
||||
is_operator: bool = False
|
||||
is_workshop_staff: bool = False
|
||||
status: str = "ACTIVE"
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class EmployeeUpdate(BaseModel):
|
||||
employee_name: str
|
||||
dept_id: int = Field(gt=0)
|
||||
mobile: str | None = None
|
||||
gender: str | None = None
|
||||
hire_date: date | None = None
|
||||
job_title: str | None = None
|
||||
shift_code: str | None = None
|
||||
manager_employee_id: int | None = None
|
||||
is_operator: bool = False
|
||||
is_workshop_staff: bool = False
|
||||
status: str = "ACTIVE"
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class MiniAppPersonnelRead(BaseModel):
|
||||
phone: str
|
||||
name: str
|
||||
role: str | None = None
|
||||
role_name: str | None = None
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
attendance_point_names: list[str] = Field(default_factory=list)
|
||||
is_temporary: bool = False
|
||||
temporary_expires_at: datetime | None = None
|
||||
temporary_expired: bool = False
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class MiniAppPersonnelUpsert(BaseModel):
|
||||
phone: str = Field(min_length=1, max_length=20)
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
roles: list[str] = Field(default_factory=lambda: ["worker"])
|
||||
attendance_point_names: list[str] = Field(default_factory=list)
|
||||
is_temporary: bool = False
|
||||
temporary_expires_at: datetime | None = None
|
||||
|
||||
|
||||
class MiniAppAttendancePointRead(BaseModel):
|
||||
name: str
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
radius_meters: int = 500
|
||||
remark: str | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class MiniAppProductRead(BaseModel):
|
||||
attendance_point_name: str
|
||||
project_no: str
|
||||
product_name: str
|
||||
profile_no: str | None = None
|
||||
material_code: str | None = None
|
||||
material_name: str | None = None
|
||||
supplier: str | None = None
|
||||
product_net_weight_kg: float | None = None
|
||||
product_gross_weight_kg: float | None = None
|
||||
allowed_scrap_rate: float | None = None
|
||||
scrap_loss_rate: float | None = None
|
||||
waste_price_yuan_per_kg: float | None = None
|
||||
device_no: str
|
||||
process_name: str | None = None
|
||||
stamping_method: str | None = None
|
||||
operator_count: float = 1
|
||||
standard_beat: float = 0
|
||||
standard_workload: float = 0
|
||||
display_name: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class MiniAppProductUpsert(BaseModel):
|
||||
attendance_point_name: str = Field(min_length=1, max_length=128)
|
||||
project_no: str = Field(min_length=1, max_length=64)
|
||||
product_name: str = Field(min_length=1, max_length=255)
|
||||
profile_no: str | None = None
|
||||
material_code: str | None = None
|
||||
material_name: str | None = None
|
||||
supplier: str | None = None
|
||||
product_net_weight_kg: float | None = Field(default=None, ge=0)
|
||||
product_gross_weight_kg: float | None = Field(default=None, ge=0)
|
||||
allowed_scrap_rate: float | None = Field(default=None, ge=0)
|
||||
scrap_loss_rate: float | None = Field(default=None, ge=0)
|
||||
waste_price_yuan_per_kg: float | None = Field(default=None, ge=0)
|
||||
device_no: str = Field(default="", max_length=64)
|
||||
process_name: str = Field(default="", max_length=128)
|
||||
stamping_method: str | None = None
|
||||
operator_count: float = Field(default=1, ge=0)
|
||||
standard_beat: float = Field(default=0, ge=0)
|
||||
standard_workload: float = Field(default=0, ge=0)
|
||||
original_attendance_point_name: str | None = None
|
||||
original_project_no: str | None = None
|
||||
original_product_name: str | None = None
|
||||
original_device_no: str | None = None
|
||||
original_process_name: str | None = None
|
||||
|
||||
|
||||
class MiniAppProductImportResult(BaseModel):
|
||||
imported: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
material_sync: dict[str, object] = Field(default_factory=dict)
|
||||
product_specs: dict[str, int] = Field(default_factory=dict)
|
||||
message: str
|
||||
|
||||
|
||||
class MiniAppMaterialWastePriceSync(BaseModel):
|
||||
material_code: str | None = None
|
||||
material_name: str | None = None
|
||||
waste_price_yuan_per_kg: float = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class WarehouseRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
warehouse_code: str
|
||||
warehouse_name: str
|
||||
warehouse_type: str
|
||||
status: str
|
||||
|
||||
|
||||
class ItemRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
item_code: str
|
||||
item_name: str
|
||||
item_type: str
|
||||
specification: str | None = None
|
||||
material_grade: str | None = None
|
||||
unit_weight_kg: float
|
||||
safety_stock_weight_kg: float
|
||||
status: str
|
||||
|
||||
|
||||
class MaterialRead(BaseModel):
|
||||
item_id: int
|
||||
item_code: str
|
||||
item_name: str
|
||||
specification: str | None = None
|
||||
material_grade: str | None = None
|
||||
unit_weight_kg: float = 0
|
||||
safety_stock_weight_kg: float = 0
|
||||
scrap_sale_price: float = 0
|
||||
default_supplier_id: int | None = None
|
||||
supplier_name: str | None = None
|
||||
purchase_calc_mode: str
|
||||
min_purchase_qty: float
|
||||
purchase_multiple_qty: float
|
||||
purchase_multiple_weight_kg: float
|
||||
lead_time_days: int
|
||||
quality_rule: str | None = None
|
||||
remark: str | None = None
|
||||
status: str = "ACTIVE"
|
||||
|
||||
|
||||
class ProductRead(BaseModel):
|
||||
item_id: int
|
||||
item_code: str
|
||||
item_name: str
|
||||
specification: str | None = None
|
||||
drawing_no: str | None = None
|
||||
product_family: str | None = None
|
||||
version_no: str
|
||||
unit_weight_kg: float
|
||||
allowed_scrap_rate: float
|
||||
standard_output_qty: float = 0
|
||||
quality_standard: str | None = None
|
||||
safety_stock_weight_kg: float = 0
|
||||
status: str
|
||||
|
||||
|
||||
class ProductCreate(BaseModel):
|
||||
item_code: str | None = None
|
||||
item_name: str
|
||||
specification: str | None = None
|
||||
drawing_no: str | None = None
|
||||
product_family: str | None = None
|
||||
version_no: str = "V1.0"
|
||||
unit_weight_kg: float = Field(gt=0)
|
||||
allowed_scrap_rate: float = Field(default=0, ge=0)
|
||||
standard_output_qty: float = Field(default=0, ge=0)
|
||||
quality_standard: str | None = None
|
||||
safety_stock_weight_kg: float = Field(default=0, ge=0)
|
||||
status: str = "ACTIVE"
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
item_code: str | None = None
|
||||
item_name: str
|
||||
specification: str | None = None
|
||||
drawing_no: str | None = None
|
||||
product_family: str | None = None
|
||||
version_no: str
|
||||
unit_weight_kg: float = Field(gt=0)
|
||||
allowed_scrap_rate: float = Field(default=0, ge=0)
|
||||
standard_output_qty: float = Field(default=0, ge=0)
|
||||
quality_standard: str | None = None
|
||||
safety_stock_weight_kg: float = Field(default=0, ge=0)
|
||||
status: str = "ACTIVE"
|
||||
|
||||
|
||||
class MaterialCreate(BaseModel):
|
||||
item_code: str | None = None
|
||||
item_name: str
|
||||
specification: str
|
||||
material_grade: str
|
||||
unit_weight_kg: float = Field(gt=0)
|
||||
safety_stock_weight_kg: float = Field(default=0, ge=0)
|
||||
scrap_sale_price: float = Field(default=0, ge=0)
|
||||
default_supplier_id: int | None = None
|
||||
purchase_calc_mode: str = "BY_WEIGHT"
|
||||
min_purchase_qty: float = Field(default=0, ge=0)
|
||||
purchase_multiple_qty: float = Field(default=0, ge=0)
|
||||
purchase_multiple_weight_kg: float = Field(default=0, ge=0)
|
||||
lead_time_days: int = Field(default=0, ge=0)
|
||||
quality_rule: str | None = None
|
||||
remark: str | None = None
|
||||
status: str = "ACTIVE"
|
||||
|
||||
|
||||
class MaterialUpdate(MaterialCreate):
|
||||
pass
|
||||
|
||||
|
||||
class BomItemPayload(BaseModel):
|
||||
seq_no: int = Field(gt=0)
|
||||
material_item_id: int = Field(gt=0)
|
||||
usage_type: str = "WEIGHT"
|
||||
qty_per_piece: float = Field(default=1, gt=0)
|
||||
net_weight_per_piece_kg: float = Field(default=0, ge=0)
|
||||
gross_weight_per_piece_kg: float = Field(default=0, ge=0)
|
||||
loss_rate: float = Field(default=0, ge=0)
|
||||
issue_over_tolerance_rate: float = Field(default=0, ge=0)
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class BomItemRead(BomItemPayload):
|
||||
bom_item_id: int
|
||||
material_code: str
|
||||
material_name: str
|
||||
seq_no: int
|
||||
material_item_id: int
|
||||
usage_type: str
|
||||
qty_per_piece: float
|
||||
net_weight_per_piece_kg: float
|
||||
gross_weight_per_piece_kg: float
|
||||
loss_rate: float
|
||||
issue_over_tolerance_rate: float
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class BomRead(BaseModel):
|
||||
bom_id: int
|
||||
bom_code: str
|
||||
product_item_id: int
|
||||
product_code: str
|
||||
product_name: str
|
||||
version_no: str
|
||||
is_default: bool
|
||||
status: str
|
||||
effective_date: date | None = None
|
||||
expire_date: date | None = None
|
||||
remark: str | None = None
|
||||
item_count: int = 0
|
||||
items: list[BomItemRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BomUpsert(BaseModel):
|
||||
bom_code: str | None = None
|
||||
product_item_id: int
|
||||
version_no: str = "V1.0"
|
||||
is_default: bool = True
|
||||
status: str = "ACTIVE"
|
||||
effective_date: date | None = None
|
||||
expire_date: date | None = None
|
||||
remark: str | None = None
|
||||
items: list[BomItemPayload]
|
||||
|
||||
|
||||
class ProcessRead(BaseModel):
|
||||
process_id: int
|
||||
process_code: str
|
||||
process_name: str
|
||||
process_type: str
|
||||
is_report_required: bool
|
||||
is_quality_gate: bool
|
||||
status: str
|
||||
|
||||
|
||||
class RouteOperationPayload(BaseModel):
|
||||
seq_no: int = Field(gt=0)
|
||||
process_id: int = Field(gt=0)
|
||||
work_center_id: int = Field(gt=0)
|
||||
operation_name: str
|
||||
std_setup_minutes: float = Field(default=0, ge=0)
|
||||
std_cycle_seconds: float = Field(default=0, ge=0)
|
||||
std_labor_cost: float = Field(default=0, ge=0)
|
||||
std_machine_cost: float = Field(default=0, ge=0)
|
||||
std_outsource_cost: float = Field(default=0, ge=0)
|
||||
allowed_scrap_rate: float = Field(default=0, ge=0)
|
||||
report_required: bool = True
|
||||
is_key_operation: bool = False
|
||||
status: str = "ACTIVE"
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class RouteOperationRead(RouteOperationPayload):
|
||||
route_operation_id: int
|
||||
process_code: str
|
||||
process_name: str
|
||||
stamping_method: str | None = None
|
||||
work_center_code: str
|
||||
work_center_name: str
|
||||
seq_no: int
|
||||
process_id: int
|
||||
work_center_id: int
|
||||
operation_name: str
|
||||
std_setup_minutes: float
|
||||
std_cycle_seconds: float
|
||||
std_labor_cost: float
|
||||
std_machine_cost: float
|
||||
std_outsource_cost: float
|
||||
allowed_scrap_rate: float
|
||||
report_required: bool
|
||||
is_key_operation: bool
|
||||
status: str
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class ProcessRouteRead(BaseModel):
|
||||
route_id: int
|
||||
route_code: str
|
||||
route_name: str
|
||||
product_item_id: int
|
||||
product_code: str
|
||||
product_name: str
|
||||
version_no: str
|
||||
is_default: bool
|
||||
status: str
|
||||
effective_date: date | None = None
|
||||
expire_date: date | None = None
|
||||
remark: str | None = None
|
||||
operation_count: int = 0
|
||||
operations: list[RouteOperationRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProcessRouteUpsert(BaseModel):
|
||||
route_code: str | None = None
|
||||
route_name: str
|
||||
product_item_id: int
|
||||
version_no: str = "V1.0"
|
||||
is_default: bool = True
|
||||
status: str = "ACTIVE"
|
||||
effective_date: date | None = None
|
||||
expire_date: date | None = None
|
||||
remark: str | None = None
|
||||
operations: list[RouteOperationPayload]
|
||||
|
||||
|
||||
class SalesOrderRead(BaseModel):
|
||||
order_id: int
|
||||
order_no: str
|
||||
customer_id: int
|
||||
customer_code: str
|
||||
customer_name: str
|
||||
sales_employee_id: int | None = None
|
||||
sales_employee_name: str | None = None
|
||||
order_date: date
|
||||
promised_date: date | None = None
|
||||
delivery_address: str | None = None
|
||||
total_amount: float
|
||||
status: str
|
||||
line_count: int
|
||||
mrp_count: int = 0
|
||||
purchase_order_count: int = 0
|
||||
delivery_count: int = 0
|
||||
delivery_item_count: int = 0
|
||||
work_order_count: int = 0
|
||||
can_delete: bool = False
|
||||
archive_status: str = "未生成"
|
||||
archive_version: int | None = None
|
||||
archive_created_at: datetime | None = None
|
||||
archive_error_message: str | None = None
|
||||
|
||||
|
||||
class CustomerRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
customer_code: str
|
||||
customer_name: str
|
||||
short_name: str | None = None
|
||||
contact_name: str | None = None
|
||||
contact_phone: str | None = None
|
||||
contacts: list[dict[str, str]] = Field(default_factory=list, max_length=3)
|
||||
address: str | None = None
|
||||
credit_days: int = 0
|
||||
status: str
|
||||
|
||||
|
||||
class CustomerCreate(BaseModel):
|
||||
customer_name: str
|
||||
short_name: str | None = None
|
||||
contact_name: str | None = None
|
||||
contact_phone: str | None = None
|
||||
contacts: list[dict[str, str]] = Field(default_factory=list, max_length=3)
|
||||
address: str | None = None
|
||||
tax_no: str | None = None
|
||||
credit_days: int = 30
|
||||
|
||||
|
||||
class SalesOrderItemCreate(BaseModel):
|
||||
product_item_id: int
|
||||
order_qty: float
|
||||
unit_price: float
|
||||
promised_date: date | None = None
|
||||
customer_part_no: str | None = None
|
||||
|
||||
|
||||
class SalesOrderCreate(BaseModel):
|
||||
customer_id: int
|
||||
sales_employee_id: int | None = None
|
||||
promised_date: date | None = None
|
||||
delivery_address: str | None = None
|
||||
tax_rate: float = 0
|
||||
remark: str | None = None
|
||||
items: list[SalesOrderItemCreate]
|
||||
|
||||
|
||||
class SalesOrderItemRead(BaseModel):
|
||||
sales_order_item_id: int
|
||||
sales_order_id: int
|
||||
order_no: str
|
||||
line_no: int
|
||||
product_item_id: int
|
||||
product_code: str
|
||||
product_name: str
|
||||
customer_part_no: str | None = None
|
||||
order_qty: float
|
||||
delivered_qty: float
|
||||
unit_price: float
|
||||
line_amount: float
|
||||
promised_date: date | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class SalesOrderCreateResult(BaseModel):
|
||||
order_id: int
|
||||
order_no: str
|
||||
total_amount: float
|
||||
item_count: int
|
||||
status: str
|
||||
archive_status: str = "未生成"
|
||||
archive_version: int | None = None
|
||||
archive_error_message: str | None = None
|
||||
|
||||
|
||||
class MaterialDemandRead(BaseModel):
|
||||
demand_id: int
|
||||
demand_no: str
|
||||
order_no: str
|
||||
material_item_id: int
|
||||
product_code: str
|
||||
product_name: str
|
||||
material_code: str
|
||||
material_name: str
|
||||
purchase_calc_mode: str
|
||||
order_qty: float
|
||||
theoretical_weight_kg: float
|
||||
planned_weight_kg: float
|
||||
shortage_weight_kg: float
|
||||
suggested_purchase_qty: float
|
||||
suggested_purchase_weight_kg: float
|
||||
status: str
|
||||
generated_at: datetime | None = None
|
||||
|
||||
|
||||
class MrpGenerationResult(BaseModel):
|
||||
sales_order_id: int
|
||||
order_no: str
|
||||
generated_count: int
|
||||
demand_rows: list[MaterialDemandRead]
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class DocumentArchiveRead(BaseModel):
|
||||
archive_id: int
|
||||
document_type: str
|
||||
business_id: int
|
||||
document_no: str
|
||||
archive_version: int
|
||||
template_version: str
|
||||
file_format: str
|
||||
file_name: str
|
||||
status: str
|
||||
error_message: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DocumentArchiveGenerateResult(BaseModel):
|
||||
business_id: int
|
||||
document_type: str
|
||||
document_no: str
|
||||
archive_status: str
|
||||
archive_version: int | None = None
|
||||
archive_error_message: str | None = None
|
||||
|
||||
|
||||
class DocumentArchiveBatchDownloadRequest(BaseModel):
|
||||
model_config = ConfigDict(validate_default=True)
|
||||
|
||||
document_type: str
|
||||
business_ids: list[int] = Field(default_factory=list, min_length=1)
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProductSummary(BaseModel):
|
||||
product_code: str
|
||||
product_name: str
|
||||
material_name: str
|
||||
order_qty: int
|
||||
unit_weight_kg: float
|
||||
gross_material_kg_per_piece: float
|
||||
planned_material_weight_kg: float
|
||||
suggested_purchase_weight_kg: float
|
||||
purchase_multiple_weight_kg: float
|
||||
|
||||
|
||||
class OperationCostLine(BaseModel):
|
||||
seq_no: int
|
||||
operation_name: str
|
||||
work_center: str
|
||||
std_labor_cost: float
|
||||
std_machine_cost: float
|
||||
allowed_scrap_rate: float
|
||||
reported_good_qty: int
|
||||
reported_scrap_qty: int
|
||||
abnormal: bool
|
||||
|
||||
|
||||
class InventoryLotSummary(BaseModel):
|
||||
lot_no: str
|
||||
item_name: str
|
||||
warehouse: str
|
||||
location: str
|
||||
inbound_weight_kg: float
|
||||
remaining_weight_kg: float
|
||||
status: str
|
||||
|
||||
|
||||
class MiniAppField(BaseModel):
|
||||
field_name: str
|
||||
label: str
|
||||
required: bool
|
||||
description: str
|
||||
|
||||
|
||||
class CostInsight(BaseModel):
|
||||
category: str
|
||||
amount: float
|
||||
ratio: float
|
||||
note: str
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PermissionRead(BaseModel):
|
||||
permission_id: int
|
||||
permission_code: str
|
||||
permission_name: str
|
||||
module_code: str
|
||||
action_code: str
|
||||
status: str
|
||||
|
||||
|
||||
class MenuPermissionNode(BaseModel):
|
||||
permission_code: str
|
||||
permission_name: str
|
||||
children: list["MenuPermissionNode"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OrgEmployeeNode(BaseModel):
|
||||
employee_id: int
|
||||
employee_name: str
|
||||
mobile: str | None = None
|
||||
job_title: str | None = None
|
||||
username: str | None = None
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
|
||||
|
||||
class OrgNodeRead(BaseModel):
|
||||
node_id: int
|
||||
node_type: str
|
||||
node_label: str
|
||||
parent_id: int | None = None
|
||||
dept_code: str
|
||||
dept_type: str
|
||||
manager_employee_id: int | None = None
|
||||
manager_name: str | None = None
|
||||
manager_employee_ids: list[int] = Field(default_factory=list)
|
||||
manager_names: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
sort_no: int = 0
|
||||
remark: str | None = None
|
||||
employees: list[OrgEmployeeNode] = Field(default_factory=list)
|
||||
children: list["OrgNodeRead"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OrgNodeCreate(BaseModel):
|
||||
parent_id: int | None = None
|
||||
node_type: str = Field(pattern="^(COMPANY|BRANCH|DEPARTMENT|GROUP)$")
|
||||
node_label: str = Field(min_length=1, max_length=100)
|
||||
dept_code: str | None = Field(default=None, max_length=50)
|
||||
dept_type: str = Field(default="ADMIN", max_length=32)
|
||||
manager_employee_id: int | None = None
|
||||
manager_employee_ids: list[int] = Field(default_factory=list)
|
||||
sort_no: int = 0
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
|
||||
|
||||
|
||||
class OrgNodeUpdate(BaseModel):
|
||||
node_label: str = Field(min_length=1, max_length=100)
|
||||
dept_type: str = Field(default="ADMIN", max_length=32)
|
||||
manager_employee_id: int | None = None
|
||||
manager_employee_ids: list[int] = Field(default_factory=list)
|
||||
sort_no: int = 0
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
|
||||
|
||||
|
||||
class OrgEmployeeBind(BaseModel):
|
||||
employee_id: int = Field(gt=0)
|
||||
sort_no: int = 0
|
||||
|
||||
|
||||
class RoleRead(BaseModel):
|
||||
role_id: int
|
||||
role_code: str
|
||||
role_name: str
|
||||
role_scope: str
|
||||
status: str
|
||||
remark: str | None = None
|
||||
permission_codes: list[str] = Field(default_factory=list)
|
||||
user_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class RoleCreate(BaseModel):
|
||||
role_code: str = Field(min_length=2, max_length=50, pattern="^[A-Z][A-Z0-9_]*$")
|
||||
role_name: str = Field(min_length=1, max_length=100)
|
||||
role_scope: str = Field(default="SYSTEM", max_length=32)
|
||||
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
|
||||
remark: str | None = Field(default=None, max_length=500)
|
||||
permission_codes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
role_name: str = Field(min_length=1, max_length=100)
|
||||
role_scope: str = Field(default="SYSTEM", max_length=32)
|
||||
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
|
||||
remark: str | None = Field(default=None, max_length=500)
|
||||
permission_codes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RolePermissionUpdate(BaseModel):
|
||||
permission_codes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemUserRead(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
employee_id: int
|
||||
employee_name: str
|
||||
dept_id: int
|
||||
dept_name: str | None = None
|
||||
mobile: str | None = None
|
||||
nickname: str | None = None
|
||||
is_super_admin: bool = False
|
||||
status: str
|
||||
role_ids: list[int] = Field(default_factory=list)
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
last_login_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class SystemUserCreate(BaseModel):
|
||||
employee_id: int = Field(gt=0)
|
||||
role_ids: list[int] = Field(min_length=1)
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
|
||||
|
||||
|
||||
class SystemUserRoleUpdate(BaseModel):
|
||||
role_ids: list[int] = Field(min_length=1)
|
||||
|
||||
|
||||
class SystemUserStatusUpdate(BaseModel):
|
||||
status: str = Field(pattern="^(ACTIVE|INACTIVE)$")
|
||||
|
||||
|
||||
class SystemUserPasswordReset(BaseModel):
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class EmployeeOption(BaseModel):
|
||||
employee_id: int
|
||||
employee_name: str
|
||||
dept_id: int
|
||||
dept_name: str | None = None
|
||||
mobile: str | None = None
|
||||
job_title: str | None = None
|
||||
username: str | None = None
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
permission_codes: list[str] = Field(default_factory=list)
|
||||
has_system_user: bool = False
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import get_db
|
||||
from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole
|
||||
from app.schemas.database import AuthSession, LoginUser
|
||||
|
||||
PBKDF2_ITERATIONS = 600000
|
||||
|
||||
ROLE_TITLE_MAP = {
|
||||
"ADMIN": "总控驾驶舱",
|
||||
"SALES": "订单与客户协同",
|
||||
"PLANNER": "MRP与排产控制",
|
||||
"PURCHASER": "采购执行中心",
|
||||
"WAREHOUSE": "仓储作业中心",
|
||||
"SUPERVISOR": "车间调度中心",
|
||||
"OPERATOR": "现场报工中心",
|
||||
"FINANCE": "成本与财务中心",
|
||||
"EQUIP_ADMIN": "设备维护中心",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
user: User
|
||||
employee: Employee
|
||||
department: Department | None
|
||||
role_codes: list[str]
|
||||
role_names: list[str]
|
||||
permission_codes: list[str]
|
||||
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False, description="登录接口返回的 Bearer Token")
|
||||
|
||||
|
||||
def hash_password(password: str, salt: str | None = None) -> str:
|
||||
salt_bytes = os.urandom(16) if salt is None else salt.encode("utf-8")
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, PBKDF2_ITERATIONS)
|
||||
return (
|
||||
f"pbkdf2_sha256${PBKDF2_ITERATIONS}$"
|
||||
f"{base64.urlsafe_b64encode(salt_bytes).decode('utf-8')}$"
|
||||
f"{base64.urlsafe_b64encode(digest).decode('utf-8')}"
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, stored_hash: str) -> bool:
|
||||
try:
|
||||
algorithm, iterations, salt_b64, digest_b64 = stored_hash.split("$", 3)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
|
||||
salt = base64.urlsafe_b64decode(salt_b64.encode("utf-8"))
|
||||
expected = base64.urlsafe_b64decode(digest_b64.encode("utf-8"))
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
def get_auth_context(db: Session, username: str) -> AuthContext:
|
||||
stmt = (
|
||||
select(User, Employee, Department)
|
||||
.join(Employee, Employee.id == User.employee_id)
|
||||
.outerjoin(Department, Department.id == User.dept_id)
|
||||
.where(User.username == username, User.status == "ACTIVE")
|
||||
)
|
||||
row = db.execute(stmt).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
user, employee, department = row
|
||||
|
||||
role_rows = db.execute(
|
||||
select(Role.role_code, Role.role_name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user.id, Role.status == "ACTIVE")
|
||||
.order_by(Role.id)
|
||||
).all()
|
||||
role_codes = [row[0] for row in role_rows]
|
||||
role_names = [row[1] for row in role_rows]
|
||||
|
||||
permission_rows = db.execute(
|
||||
select(Permission.permission_code)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.join(UserRole, UserRole.role_id == RolePermission.role_id)
|
||||
.where(
|
||||
UserRole.user_id == user.id,
|
||||
Permission.status == "ACTIVE",
|
||||
)
|
||||
.group_by(Permission.permission_code)
|
||||
.order_by(Permission.permission_code)
|
||||
).all()
|
||||
permission_codes = [row[0] for row in permission_rows]
|
||||
if user.is_super_admin or "ADMIN" in role_codes:
|
||||
for permission_code in ("MENU_SYSTEM_EXTENSION", "MENU_SYSTEM_PERMISSION"):
|
||||
if permission_code not in permission_codes:
|
||||
permission_codes.append(permission_code)
|
||||
|
||||
return AuthContext(
|
||||
user=user,
|
||||
employee=employee,
|
||||
department=department,
|
||||
role_codes=role_codes,
|
||||
role_names=role_names,
|
||||
permission_codes=permission_codes,
|
||||
)
|
||||
|
||||
|
||||
def login_with_password(db: Session, username: str, password: str) -> AuthSession:
|
||||
context = get_auth_context(db, username)
|
||||
if not verify_password(password, context.user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
context.user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db.add(context.user)
|
||||
db.commit()
|
||||
return build_auth_session(context)
|
||||
|
||||
|
||||
def build_login_user(context: AuthContext) -> LoginUser:
|
||||
title = ROLE_TITLE_MAP.get(context.role_codes[0], "工业风管理台") if context.role_codes else "工业风管理台"
|
||||
return LoginUser(
|
||||
user_id=context.user.id,
|
||||
username=context.user.username,
|
||||
employee_id=context.employee.id,
|
||||
mobile=context.employee.mobile,
|
||||
display_name=context.user.nickname or context.employee.employee_name,
|
||||
title=title,
|
||||
dept_name=context.department.dept_name if context.department else None,
|
||||
role_codes=context.role_codes,
|
||||
role_names=context.role_names,
|
||||
permission_codes=context.permission_codes,
|
||||
)
|
||||
|
||||
|
||||
def build_auth_session(context: AuthContext) -> AuthSession:
|
||||
access_token, expires_at = create_access_token(context)
|
||||
return AuthSession(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_at=expires_at,
|
||||
**build_login_user(context).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
def next_employee_code(db: Session) -> str:
|
||||
total = db.scalar(select(func.count(Employee.id))) or 0
|
||||
return f"EMP-{int(total) + 1:04d}"
|
||||
|
||||
|
||||
def create_access_token(context: AuthContext) -> tuple[str, datetime]:
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(minutes=settings.auth_token_expire_minutes)
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {
|
||||
"sub": context.user.username,
|
||||
"uid": context.user.id,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
}
|
||||
encoded_header = _urlsafe_b64encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
|
||||
encoded_payload = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
signing_input = f"{encoded_header}.{encoded_payload}"
|
||||
signature = _sign_token(signing_input, settings.auth_secret_key)
|
||||
return f"{signing_input}.{signature}", expires_at
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
try:
|
||||
encoded_header, encoded_payload, signature = token.split(".", 2)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=401, detail="登录凭证无效") from exc
|
||||
|
||||
signing_input = f"{encoded_header}.{encoded_payload}"
|
||||
expected_signature = _sign_token(signing_input, settings.auth_secret_key)
|
||||
if not hmac.compare_digest(expected_signature, signature):
|
||||
raise HTTPException(status_code=401, detail="登录凭证无效")
|
||||
|
||||
try:
|
||||
payload = json.loads(_urlsafe_b64decode(encoded_payload).decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=401, detail="登录凭证无效") from exc
|
||||
|
||||
expires_at = payload.get("exp")
|
||||
username = payload.get("sub")
|
||||
if not isinstance(expires_at, int) or not username:
|
||||
raise HTTPException(status_code=401, detail="登录凭证无效")
|
||||
if expires_at <= int(datetime.now(timezone.utc).timestamp()):
|
||||
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def get_current_auth_context(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthContext:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
||||
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
return get_auth_context(db, str(payload["sub"]))
|
||||
|
||||
|
||||
def require_authenticated_user(
|
||||
context: Annotated[AuthContext, Depends(get_current_auth_context)],
|
||||
) -> AuthContext:
|
||||
return context
|
||||
|
||||
|
||||
def require_system_admin(
|
||||
context: Annotated[AuthContext, Depends(get_current_auth_context)],
|
||||
) -> AuthContext:
|
||||
if context.user.is_super_admin or "ADMIN" in context.role_codes:
|
||||
return context
|
||||
raise HTTPException(status_code=403, detail="仅系统管理员可访问系统管理")
|
||||
|
||||
|
||||
def _sign_token(signing_input: str, secret_key: str) -> str:
|
||||
digest = hmac.new(secret_key.encode("utf-8"), signing_input.encode("utf-8"), hashlib.sha256).digest()
|
||||
return _urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def _urlsafe_b64encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def _urlsafe_b64decode(data: str) -> bytes:
|
||||
padding = "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(f"{data}{padding}".encode("utf-8"))
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CONTACT_SEPARATOR = "、"
|
||||
MAX_CONTACTS = 3
|
||||
|
||||
|
||||
def _clean_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _entry_value(entry: Any, *keys: str) -> str:
|
||||
for key in keys:
|
||||
if isinstance(entry, dict):
|
||||
value = entry.get(key)
|
||||
else:
|
||||
value = getattr(entry, key, None)
|
||||
cleaned = _clean_text(value)
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return ""
|
||||
|
||||
|
||||
def _split_contacts(value: str | None) -> list[str]:
|
||||
text = _clean_text(value)
|
||||
if not text:
|
||||
return []
|
||||
return [part.strip() for part in re.split(r"[、,,;;\n]+", text) if part.strip()]
|
||||
|
||||
|
||||
def normalize_contacts(
|
||||
contacts: list[Any] | None,
|
||||
*,
|
||||
fallback_name: str | None = None,
|
||||
fallback_phone: str | None = None,
|
||||
max_contacts: int = MAX_CONTACTS,
|
||||
) -> list[dict[str, str]]:
|
||||
normalized: list[dict[str, str]] = []
|
||||
|
||||
for entry in contacts or []:
|
||||
contact_name = _entry_value(entry, "contact_name", "name")
|
||||
contact_phone = _entry_value(entry, "contact_phone", "phone")
|
||||
if not contact_name and not contact_phone:
|
||||
continue
|
||||
normalized.append({"contact_name": contact_name, "contact_phone": contact_phone})
|
||||
|
||||
if not normalized:
|
||||
contact_name = _clean_text(fallback_name)
|
||||
contact_phone = _clean_text(fallback_phone)
|
||||
if contact_name or contact_phone:
|
||||
normalized.append({"contact_name": contact_name, "contact_phone": contact_phone})
|
||||
|
||||
if len(normalized) > max_contacts:
|
||||
raise ValueError(f"联系人最多维护 {max_contacts} 位")
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def serialize_contacts(contacts: list[dict[str, str]]) -> tuple[str | None, str | None]:
|
||||
names = [item["contact_name"] for item in contacts if item.get("contact_name")]
|
||||
phones = [item["contact_phone"] for item in contacts if item.get("contact_phone")]
|
||||
return (
|
||||
CONTACT_SEPARATOR.join(names) if names else None,
|
||||
CONTACT_SEPARATOR.join(phones) if phones else None,
|
||||
)
|
||||
|
||||
|
||||
def parse_contacts(contact_name: str | None, contact_phone: str | None, *, max_contacts: int = MAX_CONTACTS) -> list[dict[str, str]]:
|
||||
names = _split_contacts(contact_name)
|
||||
phones = _split_contacts(contact_phone)
|
||||
total = min(max(len(names), len(phones)), max_contacts)
|
||||
return [
|
||||
{
|
||||
"contact_name": names[index] if index < len(names) else "",
|
||||
"contact_phone": phones[index] if index < len(phones) else "",
|
||||
}
|
||||
for index in range(total)
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.operations import to_decimal
|
||||
|
||||
|
||||
def normalize_logistics_fields(
|
||||
waybill_no: str | None,
|
||||
freight_amount: Any,
|
||||
*,
|
||||
required: bool,
|
||||
order_photo_url: str | None = None,
|
||||
freight_required: bool | None = None,
|
||||
) -> tuple[str | None, Decimal | None, str | None]:
|
||||
clean_waybill = str(waybill_no or "").strip()
|
||||
clean_photo_url = str(order_photo_url or "").strip()
|
||||
freight_missing = freight_amount is None or str(freight_amount).strip() == ""
|
||||
must_have_freight = required if freight_required is None else freight_required
|
||||
|
||||
if required and (not clean_waybill and not clean_photo_url) and freight_missing and freight_required is None:
|
||||
raise HTTPException(status_code=400, detail="该出入库业务必须填写运单号或上传辅助照片,并填写运费")
|
||||
if required and (not clean_waybill and not clean_photo_url):
|
||||
raise HTTPException(status_code=400, detail="该出入库业务必须填写运单号或上传辅助照片")
|
||||
if must_have_freight and freight_missing:
|
||||
raise HTTPException(status_code=400, detail="该出入库业务必须填写运费")
|
||||
if freight_missing:
|
||||
return clean_waybill or None, None, clean_photo_url or None
|
||||
|
||||
freight = to_decimal(freight_amount, "0.01")
|
||||
if freight < 0:
|
||||
raise HTTPException(status_code=400, detail="运费不能小于0")
|
||||
return clean_waybill or None, freight, clean_photo_url or None
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.dashboard import AlertItem, BoardSection, DashboardPayload, FlowStep, OverviewMetric
|
||||
from app.schemas.domain import CostInsight, InventoryLotSummary, MiniAppField, OperationCostLine, ProductSummary
|
||||
|
||||
|
||||
def get_dashboard_payload() -> DashboardPayload:
|
||||
return DashboardPayload(
|
||||
company="宁波嘉恒智能科技有限公司",
|
||||
overview=[
|
||||
OverviewMetric(
|
||||
code="sales_open",
|
||||
label="未交订单",
|
||||
value="8",
|
||||
unit="单",
|
||||
note="其中 3 单未来 7 天交期",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="mrp_gap",
|
||||
label="待采购材料缺口",
|
||||
value="59",
|
||||
unit="吨",
|
||||
highlight="warning",
|
||||
note="SAP440 500*600*2.5 需按 2 吨倍数采购",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="wip_orders",
|
||||
label="在制工单",
|
||||
value="6",
|
||||
unit="张",
|
||||
note="2 张存在工序卡滞",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="scrap_alerts",
|
||||
label="异常报废工序",
|
||||
value="2",
|
||||
unit="处",
|
||||
highlight="danger",
|
||||
note="冲压与焊接工序超阈值",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="waiting_ship",
|
||||
label="待发货成品",
|
||||
value="3200",
|
||||
unit="件",
|
||||
note="本体产品优先发货",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="maintenance_due",
|
||||
label="设备待保养",
|
||||
value="3",
|
||||
unit="台",
|
||||
highlight="warning",
|
||||
note="含 1 台核心冲床",
|
||||
),
|
||||
],
|
||||
alerts=[
|
||||
AlertItem(
|
||||
level="danger",
|
||||
title="冲压工序报废率 4.8%",
|
||||
description="工单 MO-202604-003 的冲压工序高于允许报废率 2.0%,建议立即复核模具与板材批次。",
|
||||
owner="车间主管",
|
||||
action="检查模具并冻结异常批次",
|
||||
),
|
||||
AlertItem(
|
||||
level="warning",
|
||||
title="SAP440 材料仅剩 8 吨可用",
|
||||
description="按已排产工单计算,当前库存只能支撑 1.3 天,建议优先下达补料采购。",
|
||||
owner="采购员",
|
||||
action="生成补料采购单",
|
||||
),
|
||||
AlertItem(
|
||||
level="warning",
|
||||
title="冲床 JH-CY-03 保养到期",
|
||||
description="设备已连续运行 286 小时,超过设定保养上限 280 小时。",
|
||||
owner="设备管理员",
|
||||
action="创建保养工单",
|
||||
),
|
||||
],
|
||||
flow_board=[
|
||||
BoardSection(
|
||||
title="订单到采购",
|
||||
items=[
|
||||
FlowStep(
|
||||
step_code="sales",
|
||||
title="订单 SO-202604-001",
|
||||
status="done",
|
||||
summary="产品 本体,下单数量 10000 件,客户交期 2026-04-28。",
|
||||
quantity="10000 件",
|
||||
),
|
||||
FlowStep(
|
||||
step_code="mrp",
|
||||
title="MRP 物料需求",
|
||||
status="active",
|
||||
summary="计算出 SAP440 钢板理论需求 59000kg,按采购倍数建议下单 60000kg。",
|
||||
quantity="60 吨",
|
||||
),
|
||||
FlowStep(
|
||||
step_code="purchase",
|
||||
title="采购到货",
|
||||
status="queued",
|
||||
summary="已到货 60000kg,待入库复核与批次挂账。",
|
||||
quantity="60 吨",
|
||||
),
|
||||
],
|
||||
),
|
||||
BoardSection(
|
||||
title="领料到完工",
|
||||
items=[
|
||||
FlowStep(
|
||||
step_code="issue",
|
||||
title="生产出库",
|
||||
status="active",
|
||||
summary="第一批已领料 22000kg,第二批计划明日发料。",
|
||||
quantity="22 吨",
|
||||
),
|
||||
FlowStep(
|
||||
step_code="production",
|
||||
title="6 道工序报工",
|
||||
status="active",
|
||||
summary="冲压工序存在异常报废,焊接工序节拍偏慢。",
|
||||
quantity="在制 3800 件",
|
||||
),
|
||||
FlowStep(
|
||||
step_code="receipt",
|
||||
title="成品待入库",
|
||||
status="queued",
|
||||
summary="首批完工 3200 件,待质检确认后转成品库。",
|
||||
quantity="3200 件",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_product_overview_summary() -> ProductSummary:
|
||||
return ProductSummary(
|
||||
product_code="FG-BT-001",
|
||||
product_name="本体",
|
||||
material_name="SAP440 500*600*2.5 钢板",
|
||||
order_qty=10000,
|
||||
unit_weight_kg=5.2,
|
||||
gross_material_kg_per_piece=5.9,
|
||||
planned_material_weight_kg=59000,
|
||||
suggested_purchase_weight_kg=60000,
|
||||
purchase_multiple_weight_kg=2000,
|
||||
)
|
||||
|
||||
|
||||
def get_operation_costs() -> list[OperationCostLine]:
|
||||
return [
|
||||
OperationCostLine(
|
||||
seq_no=10,
|
||||
operation_name="开平",
|
||||
work_center="板材预处理区",
|
||||
std_labor_cost=0.12,
|
||||
std_machine_cost=0.18,
|
||||
allowed_scrap_rate=0.5,
|
||||
reported_good_qty=10000,
|
||||
reported_scrap_qty=12,
|
||||
abnormal=False,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=20,
|
||||
operation_name="冲压",
|
||||
work_center="冲压一区",
|
||||
std_labor_cost=0.35,
|
||||
std_machine_cost=0.42,
|
||||
allowed_scrap_rate=2.0,
|
||||
reported_good_qty=9580,
|
||||
reported_scrap_qty=420,
|
||||
abnormal=True,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=30,
|
||||
operation_name="去毛刺",
|
||||
work_center="后处理区",
|
||||
std_labor_cost=0.08,
|
||||
std_machine_cost=0.06,
|
||||
allowed_scrap_rate=0.8,
|
||||
reported_good_qty=9520,
|
||||
reported_scrap_qty=25,
|
||||
abnormal=False,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=40,
|
||||
operation_name="折弯",
|
||||
work_center="折弯一区",
|
||||
std_labor_cost=0.22,
|
||||
std_machine_cost=0.20,
|
||||
allowed_scrap_rate=1.0,
|
||||
reported_good_qty=9475,
|
||||
reported_scrap_qty=18,
|
||||
abnormal=False,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=50,
|
||||
operation_name="焊接",
|
||||
work_center="焊接二区",
|
||||
std_labor_cost=0.48,
|
||||
std_machine_cost=0.26,
|
||||
allowed_scrap_rate=1.5,
|
||||
reported_good_qty=9380,
|
||||
reported_scrap_qty=85,
|
||||
abnormal=True,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=60,
|
||||
operation_name="检验包装",
|
||||
work_center="成检包装区",
|
||||
std_labor_cost=0.15,
|
||||
std_machine_cost=0.04,
|
||||
allowed_scrap_rate=0.2,
|
||||
reported_good_qty=9320,
|
||||
reported_scrap_qty=8,
|
||||
abnormal=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_inventory_lots() -> list[InventoryLotSummary]:
|
||||
return [
|
||||
InventoryLotSummary(
|
||||
lot_no="RM-20260418-01",
|
||||
item_name="SAP440 500*600*2.5 钢板",
|
||||
warehouse="原材料仓",
|
||||
location="A-01-03",
|
||||
inbound_weight_kg=30000,
|
||||
remaining_weight_kg=12000,
|
||||
status="PARTIAL",
|
||||
),
|
||||
InventoryLotSummary(
|
||||
lot_no="RM-20260418-02",
|
||||
item_name="SAP440 500*600*2.5 钢板",
|
||||
warehouse="原材料仓",
|
||||
location="A-01-04",
|
||||
inbound_weight_kg=30000,
|
||||
remaining_weight_kg=26000,
|
||||
status="AVAILABLE",
|
||||
),
|
||||
InventoryLotSummary(
|
||||
lot_no="FG-20260420-01",
|
||||
item_name="本体",
|
||||
warehouse="成品仓",
|
||||
location="FG-02-01",
|
||||
inbound_weight_kg=16640,
|
||||
remaining_weight_kg=16640,
|
||||
status="WAIT_SHIP",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_mini_app_fields() -> list[MiniAppField]:
|
||||
return [
|
||||
MiniAppField(field_name="report_no", label="报工单号", required=True, description="报工流水唯一编号"),
|
||||
MiniAppField(field_name="work_order_no", label="工单号", required=True, description="与 ERP 工单主表关联"),
|
||||
MiniAppField(field_name="operation_seq", label="工序序号", required=True, description="对应工艺路线工序"),
|
||||
MiniAppField(field_name="employee_code", label="员工编码", required=True, description="报工人员账号"),
|
||||
MiniAppField(field_name="equipment_code", label="设备编码", required=False, description="采集实际作业设备"),
|
||||
MiniAppField(field_name="start_time", label="开始时间", required=True, description="工序开始加工时间"),
|
||||
MiniAppField(field_name="end_time", label="结束时间", required=True, description="工序结束加工时间"),
|
||||
MiniAppField(field_name="report_qty", label="报工数量", required=True, description="本次加工总数量"),
|
||||
MiniAppField(field_name="good_qty", label="合格数量", required=True, description="用于流转到下道工序"),
|
||||
MiniAppField(field_name="scrap_qty", label="报废数量", required=True, description="系统用于异常判定"),
|
||||
MiniAppField(field_name="rework_qty", label="返工数量", required=False, description="返工件单独计成本"),
|
||||
MiniAppField(field_name="scrap_reason", label="报废原因", required=False, description="用于工序和设备异常归因"),
|
||||
MiniAppField(field_name="downtime_minutes", label="停机分钟数", required=False, description="用于设备稼动率分析"),
|
||||
MiniAppField(field_name="remark", label="备注", required=False, description="现场异常补充说明"),
|
||||
]
|
||||
|
||||
|
||||
def get_cost_insights() -> list[CostInsight]:
|
||||
return [
|
||||
CostInsight(category="材料成本", amount=486000.0, ratio=72.4, note="含板材采购与运输"),
|
||||
CostInsight(category="人工成本", amount=82100.0, ratio=12.2, note="按工序报工工时归集"),
|
||||
CostInsight(category="设备成本", amount=46300.0, ratio=6.9, note="按设备开机工时分摊"),
|
||||
CostInsight(category="制造费用", amount=37200.0, ratio=5.5, note="房租、水电、后勤、管理"),
|
||||
CostInsight(category="异常损失", amount=20350.0, ratio=3.0, note="报废与返工"),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,406 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_FLOOR
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import Bom, BomItem, Item
|
||||
from app.models.operations import ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot
|
||||
from app.services.operations import to_decimal
|
||||
|
||||
|
||||
INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductionBatchLedgerSnapshot:
|
||||
production_ledger_id: int
|
||||
material_lot_no: str
|
||||
product_item_id: int
|
||||
material_item_id: int
|
||||
gross_weight_per_piece_kg: Decimal
|
||||
net_weight_per_piece_kg: Decimal
|
||||
total_issued_weight_kg: Decimal
|
||||
finished_inbound_qty: Decimal
|
||||
surplus_inbound_weight_kg: Decimal
|
||||
scrap_inbound_weight_kg: Decimal
|
||||
writeoff_weight_kg: Decimal
|
||||
outside_weight_kg: Decimal
|
||||
reference_finished_qty: Decimal
|
||||
used_material_weight_kg: Decimal
|
||||
theoretical_scrap_weight_kg: Decimal
|
||||
theoretical_surplus_weight_kg: Decimal
|
||||
|
||||
|
||||
def _round_weight(value: Decimal) -> Decimal:
|
||||
return to_decimal(value, "0.001")
|
||||
|
||||
|
||||
def _non_negative(value: Decimal) -> Decimal:
|
||||
return value if value > 0 else Decimal("0")
|
||||
|
||||
|
||||
def _append_txn(
|
||||
db: Session,
|
||||
*,
|
||||
ledger: ProductionBatchLedger,
|
||||
txn_type: str,
|
||||
qty_delta: Decimal = Decimal("0"),
|
||||
weight_delta_kg: Decimal = Decimal("0"),
|
||||
source_doc_type: str | None = None,
|
||||
source_doc_id: int | None = None,
|
||||
source_line_id: int | None = None,
|
||||
biz_time: datetime | None = None,
|
||||
operator_user_id: int | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedgerTxn:
|
||||
txn = ProductionBatchLedgerTxn(
|
||||
production_ledger_id=ledger.id,
|
||||
txn_type=txn_type,
|
||||
qty_delta=qty_delta,
|
||||
weight_delta_kg=weight_delta_kg,
|
||||
outside_weight_after_kg=to_decimal(ledger.outside_weight_kg),
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=biz_time or datetime.now(),
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(txn)
|
||||
db.flush()
|
||||
return txn
|
||||
|
||||
|
||||
def _get_bom_weight_profile(db: Session, product_item_id: int, material_item_id: int) -> tuple[Decimal, Decimal]:
|
||||
row = db.execute(
|
||||
select(
|
||||
BomItem.gross_weight_per_piece_kg.label("gross_weight_per_piece_kg"),
|
||||
BomItem.net_weight_per_piece_kg.label("net_weight_per_piece_kg"),
|
||||
)
|
||||
.join(Bom, Bom.id == BomItem.bom_id)
|
||||
.where(
|
||||
Bom.product_item_id == product_item_id,
|
||||
BomItem.material_item_id == material_item_id,
|
||||
Bom.status == "ACTIVE",
|
||||
)
|
||||
.order_by(Bom.is_default.desc(), BomItem.seq_no, BomItem.id)
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
gross = to_decimal(row["gross_weight_per_piece_kg"] if row else 0)
|
||||
net = to_decimal(row["net_weight_per_piece_kg"] if row else 0)
|
||||
if gross <= 0:
|
||||
product = db.get(Item, product_item_id)
|
||||
gross = to_decimal(product.unit_weight_kg if product else 0)
|
||||
if net <= 0 or net > gross:
|
||||
net = gross
|
||||
return gross, net
|
||||
|
||||
|
||||
def calculate_production_batch_ledger_snapshot(
|
||||
db: Session,
|
||||
production_ledger_id: int,
|
||||
*,
|
||||
smart_reporting_enabled: bool,
|
||||
selected_finished_qty: Decimal | None = None,
|
||||
) -> ProductionBatchLedgerSnapshot:
|
||||
_ = smart_reporting_enabled
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
gross, net = _get_bom_weight_profile(db, ledger.product_item_id, ledger.material_item_id)
|
||||
total_issued = to_decimal(ledger.total_issued_weight_kg)
|
||||
finished_qty = to_decimal(ledger.finished_inbound_qty)
|
||||
basis_qty = finished_qty + to_decimal(selected_finished_qty)
|
||||
reference_finished_qty = Decimal("0")
|
||||
if gross > 0:
|
||||
reference_finished_qty = (total_issued / gross).to_integral_value(rounding=ROUND_FLOOR)
|
||||
used_weight = _round_weight(basis_qty * gross)
|
||||
theoretical_scrap = _round_weight(_non_negative(basis_qty * _non_negative(gross - net) - to_decimal(ledger.scrap_inbound_weight_kg)))
|
||||
theoretical_surplus = _round_weight(_non_negative(total_issued - used_weight - to_decimal(ledger.surplus_inbound_weight_kg)))
|
||||
return ProductionBatchLedgerSnapshot(
|
||||
production_ledger_id=ledger.id,
|
||||
material_lot_no=ledger.material_lot_no,
|
||||
product_item_id=ledger.product_item_id,
|
||||
material_item_id=ledger.material_item_id,
|
||||
gross_weight_per_piece_kg=gross,
|
||||
net_weight_per_piece_kg=net,
|
||||
total_issued_weight_kg=total_issued,
|
||||
finished_inbound_qty=finished_qty,
|
||||
surplus_inbound_weight_kg=to_decimal(ledger.surplus_inbound_weight_kg),
|
||||
scrap_inbound_weight_kg=to_decimal(ledger.scrap_inbound_weight_kg),
|
||||
writeoff_weight_kg=to_decimal(ledger.writeoff_weight_kg),
|
||||
outside_weight_kg=to_decimal(ledger.outside_weight_kg),
|
||||
reference_finished_qty=reference_finished_qty,
|
||||
used_material_weight_kg=used_weight,
|
||||
theoretical_scrap_weight_kg=theoretical_scrap,
|
||||
theoretical_surplus_weight_kg=theoretical_surplus,
|
||||
)
|
||||
|
||||
|
||||
def post_finished_inbound_to_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
production_ledger_id: int,
|
||||
finished_qty: Decimal,
|
||||
operator_user_id: int | None,
|
||||
source_doc_type: str | None,
|
||||
source_doc_id: int | None,
|
||||
source_line_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
qty = to_decimal(finished_qty)
|
||||
if qty < 0:
|
||||
raise HTTPException(status_code=400, detail="成品入库数量不能小于0")
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
gross, _ = _get_bom_weight_profile(db, ledger.product_item_id, ledger.material_item_id)
|
||||
used_weight = _round_weight(qty * gross)
|
||||
ledger.finished_inbound_qty = to_decimal(ledger.finished_inbound_qty) + qty
|
||||
ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - used_weight))
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="成品入库",
|
||||
qty_delta=qty,
|
||||
weight_delta_kg=-used_weight,
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=biz_time,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "成品入库联动生产台账",
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def post_surplus_inbound_to_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
production_ledger_id: int,
|
||||
surplus_weight_kg: Decimal,
|
||||
operator_user_id: int | None,
|
||||
source_doc_type: str | None,
|
||||
source_doc_id: int | None,
|
||||
source_line_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
weight = _round_weight(to_decimal(surplus_weight_kg))
|
||||
if weight < 0:
|
||||
raise HTTPException(status_code=400, detail="余料入库重量不能小于0")
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
ledger.surplus_inbound_weight_kg = _round_weight(to_decimal(ledger.surplus_inbound_weight_kg) + weight)
|
||||
ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - weight))
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="生产余料入库",
|
||||
weight_delta_kg=-weight,
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=biz_time,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "生产余料入库联动生产台账",
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def post_scrap_inbound_to_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
production_ledger_id: int,
|
||||
scrap_weight_kg: Decimal,
|
||||
operator_user_id: int | None,
|
||||
source_doc_type: str | None,
|
||||
source_doc_id: int | None,
|
||||
source_line_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
weight = _round_weight(to_decimal(scrap_weight_kg))
|
||||
if weight < 0:
|
||||
raise HTTPException(status_code=400, detail="废料入库重量不能小于0")
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
ledger.scrap_inbound_weight_kg = _round_weight(to_decimal(ledger.scrap_inbound_weight_kg) + weight)
|
||||
ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - weight))
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="生产废料入库",
|
||||
weight_delta_kg=-weight,
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=biz_time,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "生产废料入库联动生产台账",
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def _get_production_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
material_lot_id: int,
|
||||
product_item_id: int,
|
||||
) -> ProductionBatchLedger | None:
|
||||
return db.scalar(
|
||||
select(ProductionBatchLedger)
|
||||
.where(
|
||||
ProductionBatchLedger.material_lot_id == material_lot_id,
|
||||
ProductionBatchLedger.product_item_id == product_item_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def record_production_issue_to_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
material_lot_id: int,
|
||||
product_item_id: int,
|
||||
issued_weight_kg: Decimal,
|
||||
operator_user_id: int | None,
|
||||
source_doc_type: str | None,
|
||||
source_doc_id: int | None,
|
||||
source_line_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
issued_weight = _round_weight(to_decimal(issued_weight_kg))
|
||||
if issued_weight <= 0:
|
||||
raise HTTPException(status_code=400, detail="生产出库重量必须大于0")
|
||||
lot = db.get(StockLot, material_lot_id)
|
||||
if not lot:
|
||||
raise HTTPException(status_code=404, detail="材料库存批次号不存在")
|
||||
product = db.get(Item, product_item_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
now = biz_time or datetime.now()
|
||||
ledger = _get_production_ledger(db, material_lot_id=material_lot_id, product_item_id=product_item_id)
|
||||
if ledger and str(ledger.status or "") == "锁单":
|
||||
ledger.status = "在生产"
|
||||
ledger.miniapp_selectable = 1
|
||||
ledger.reopened_at = now
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="重新开工",
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark="锁单后再次生产出库,生产台账重新进入在生产状态",
|
||||
)
|
||||
|
||||
if not ledger:
|
||||
ledger = ProductionBatchLedger(
|
||||
material_lot_id=material_lot_id,
|
||||
material_lot_no=lot.lot_no,
|
||||
material_item_id=lot.item_id,
|
||||
product_item_id=product_item_id,
|
||||
status="在生产",
|
||||
miniapp_selectable=1,
|
||||
total_issued_weight_kg=Decimal("0"),
|
||||
outside_weight_kg=Decimal("0"),
|
||||
finished_inbound_qty=Decimal("0"),
|
||||
surplus_inbound_weight_kg=Decimal("0"),
|
||||
scrap_inbound_weight_kg=Decimal("0"),
|
||||
writeoff_weight_kg=Decimal("0"),
|
||||
first_issue_time=now,
|
||||
last_issue_time=now,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
|
||||
ledger.status = "在生产"
|
||||
ledger.miniapp_selectable = 1
|
||||
ledger.total_issued_weight_kg = _round_weight(to_decimal(ledger.total_issued_weight_kg) + issued_weight)
|
||||
ledger.outside_weight_kg = _round_weight(to_decimal(ledger.outside_weight_kg) + issued_weight)
|
||||
ledger.last_issue_time = now
|
||||
if not ledger.first_issue_time:
|
||||
ledger.first_issue_time = now
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="生产出库",
|
||||
weight_delta_kg=issued_weight,
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "生产出库累计到生产台账",
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def lock_production_batch_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
production_ledger_id: int,
|
||||
operator_user_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
|
||||
now = biz_time or datetime.now()
|
||||
remaining = _round_weight(to_decimal(ledger.outside_weight_kg))
|
||||
if remaining > 0:
|
||||
ledger.writeoff_weight_kg = _round_weight(to_decimal(ledger.writeoff_weight_kg) + remaining)
|
||||
ledger.outside_weight_kg = Decimal("0")
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="结单核销",
|
||||
weight_delta_kg=-remaining,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "该批材料结单时确认库外剩余重量已无现场存量",
|
||||
)
|
||||
|
||||
ledger.status = "锁单"
|
||||
ledger.miniapp_selectable = 0
|
||||
ledger.locked_at = now
|
||||
ledger.lock_count = int(ledger.lock_count or 0) + 1
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="该批材料结单",
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "该批材料已结单,小程序不再显示该材料库存批次号",
|
||||
)
|
||||
return ledger
|
||||
@@ -0,0 +1,553 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_FLOOR
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import BomItem, Item, Warehouse
|
||||
from app.models.operations import (
|
||||
CompletionReceipt,
|
||||
CompletionReceiptItem,
|
||||
InventoryTxn,
|
||||
StockLot,
|
||||
WorkOrder,
|
||||
WorkOrderMaterial,
|
||||
WorkOrderMaterialIssue,
|
||||
)
|
||||
from app.schemas.operations import (
|
||||
ProductionWorkOrderInboundCreate,
|
||||
ProductionWorkOrderInboundPreviewRead,
|
||||
ProductionWorkOrderInboundRead,
|
||||
)
|
||||
from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, generate_document_archive
|
||||
from app.services.operations import create_inventory_txn, to_decimal, upsert_stock_balance
|
||||
from app.services.production_work_order_ledger import get_work_order_limit_snapshot
|
||||
from app.services.sales_planning import code_token, next_doc_no
|
||||
from app.services.stocktake import ensure_warehouses_unlocked
|
||||
|
||||
|
||||
DEVIATION_RATE = Decimal("0.15")
|
||||
FINISHED_QTY_UPPER_MULTIPLIER = Decimal("1.15")
|
||||
|
||||
|
||||
def _production_document_batch_no(now: datetime) -> str:
|
||||
return f"生产结算{now.strftime('%Y%m%d%H%M%S')}{uuid4().int % 1_000_000:06d}"
|
||||
|
||||
|
||||
def _with_document_batch_no(remark: str, batch_no: str | None) -> str:
|
||||
if not batch_no:
|
||||
return remark
|
||||
return f"{remark};单据批次号:{batch_no}"
|
||||
|
||||
|
||||
def _round_decimal(value: Decimal, places: str = "0.001") -> Decimal:
|
||||
return to_decimal(value, places)
|
||||
|
||||
|
||||
def _non_negative(value: Decimal) -> Decimal:
|
||||
return max(to_decimal(value), Decimal("0"))
|
||||
|
||||
|
||||
def _deviation_exceeds_limit(actual: Decimal, theoretical: Decimal) -> bool:
|
||||
actual = to_decimal(actual)
|
||||
theoretical = to_decimal(theoretical)
|
||||
if theoretical == 0:
|
||||
return actual != 0
|
||||
return abs(actual - theoretical) / abs(theoretical) > DEVIATION_RATE
|
||||
|
||||
|
||||
def _floor_qty(value: Decimal) -> Decimal:
|
||||
return to_decimal(value).to_integral_value(rounding=ROUND_FLOOR)
|
||||
|
||||
|
||||
def _reference_finished_qty(issued_weight: Decimal, gross_weight: Decimal) -> Decimal:
|
||||
issued_weight = to_decimal(issued_weight)
|
||||
gross_weight = to_decimal(gross_weight)
|
||||
if issued_weight <= 0 or gross_weight <= 0:
|
||||
return Decimal("0")
|
||||
return _floor_qty(issued_weight / gross_weight)
|
||||
|
||||
|
||||
def _finished_qty_exceeds_reference_limit(total_finished_qty: Decimal, reference_finished_qty: Decimal) -> bool:
|
||||
total_finished_qty = to_decimal(total_finished_qty)
|
||||
reference_finished_qty = to_decimal(reference_finished_qty)
|
||||
if reference_finished_qty <= 0:
|
||||
return total_finished_qty > 0
|
||||
return total_finished_qty > reference_finished_qty * FINISHED_QTY_UPPER_MULTIPLIER
|
||||
|
||||
|
||||
def _remark_with_deviation(base: str, deviation_remark: str | None) -> str:
|
||||
deviation = str(deviation_remark or "").strip()
|
||||
if not deviation:
|
||||
return base
|
||||
return f"{base};偏差说明:{deviation}"
|
||||
|
||||
|
||||
def _source_issue(db: Session, work_order_id: int) -> WorkOrderMaterialIssue:
|
||||
issues = db.scalars(
|
||||
select(WorkOrderMaterialIssue)
|
||||
.where(WorkOrderMaterialIssue.work_order_id == work_order_id)
|
||||
.order_by(WorkOrderMaterialIssue.id)
|
||||
).all()
|
||||
if not issues:
|
||||
raise HTTPException(status_code=400, detail="该工单没有材料库存批次领用记录,不能进行生产工单入库")
|
||||
source_lot_ids = {int(issue.source_lot_id) for issue in issues if issue.source_lot_id is not None}
|
||||
if not source_lot_ids:
|
||||
raise HTTPException(status_code=400, detail="该工单没有材料来源库存批次,不能进行生产工单入库")
|
||||
if len(source_lot_ids) > 1:
|
||||
raise HTTPException(status_code=400, detail="该工单存在多个材料来源库存批次,不能进行生产工单入库")
|
||||
source_lot_id = next(iter(source_lot_ids))
|
||||
return next(issue for issue in issues if issue.source_lot_id is not None and int(issue.source_lot_id) == source_lot_id)
|
||||
|
||||
|
||||
def _get_work_order(db: Session, work_order_id: int) -> WorkOrder:
|
||||
work_order = db.get(WorkOrder, work_order_id)
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=404, detail="生产工单不存在")
|
||||
if str(work_order.status or "").upper() == "SETTLED":
|
||||
raise HTTPException(status_code=400, detail="该生产工单已结单,不能进行生产工单入库")
|
||||
return work_order
|
||||
|
||||
|
||||
def _get_warehouse(db: Session, warehouse_type: str) -> Warehouse:
|
||||
warehouse = db.scalar(
|
||||
select(Warehouse)
|
||||
.where(
|
||||
Warehouse.warehouse_type == warehouse_type,
|
||||
Warehouse.status == "ACTIVE",
|
||||
)
|
||||
.order_by(Warehouse.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not warehouse:
|
||||
warehouse_type_name = {"FINISHED": "成品", "SCRAP": "废料"}.get(warehouse_type, "目标")
|
||||
raise HTTPException(status_code=400, detail=f"缺少启用的{warehouse_type_name}仓库,不能进行生产工单入库")
|
||||
return warehouse
|
||||
|
||||
|
||||
def _get_bom_item(db: Session, work_order: WorkOrder, material_item_id: int) -> BomItem:
|
||||
bom_item = db.scalar(
|
||||
select(BomItem)
|
||||
.where(
|
||||
BomItem.bom_id == work_order.bom_id,
|
||||
BomItem.material_item_id == material_item_id,
|
||||
)
|
||||
.order_by(BomItem.seq_no, BomItem.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not bom_item:
|
||||
raise HTTPException(status_code=400, detail="工单需用清单缺少领用材料,不能进行生产工单入库")
|
||||
return bom_item
|
||||
|
||||
|
||||
def _build_receipt_no(db: Session, work_order: WorkOrder, now: datetime) -> str:
|
||||
work_order_part = code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18)
|
||||
return next_doc_no(db, CompletionReceipt, "receipt_no", f"WOI-NB-{now.strftime('%Y%m%d')}-{work_order_part}")
|
||||
|
||||
|
||||
def _build_lot_no(db: Session, prefix: str, work_order: WorkOrder, item: Item, now: datetime) -> str:
|
||||
item_part = code_token(item.item_code or item.item_name, f"ITEM{item.id:04d}", 12)
|
||||
work_order_part = code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 10)
|
||||
return next_doc_no(db, StockLot, "lot_no", f"{prefix}-NB-{now.strftime('%Y%m%d')}-{item_part}-{work_order_part}")
|
||||
|
||||
|
||||
def _append_settle_remark(work_order: WorkOrder, deviation_remark: str | None, remark: str | None) -> None:
|
||||
parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()]
|
||||
parts.append("生产工单入库已结单")
|
||||
if str(deviation_remark or "").strip():
|
||||
parts.append(f"偏差说明:{str(deviation_remark).strip()}")
|
||||
if str(remark or "").strip():
|
||||
parts.append(f"入库说明:{str(remark).strip()}")
|
||||
work_order.remark = ";".join(dict.fromkeys(parts))[-255:]
|
||||
|
||||
|
||||
def preview_production_work_order_inbound(
|
||||
db: Session,
|
||||
*,
|
||||
work_order_id: int,
|
||||
finished_qty: float | Decimal | None = None,
|
||||
) -> ProductionWorkOrderInboundPreviewRead:
|
||||
work_order = _get_work_order(db, work_order_id)
|
||||
issue = _source_issue(db, work_order_id)
|
||||
source_lot = db.get(StockLot, issue.source_lot_id)
|
||||
product = db.get(Item, work_order.product_item_id)
|
||||
material = db.get(Item, issue.material_item_id)
|
||||
if not source_lot:
|
||||
raise HTTPException(status_code=400, detail="来源库存批次不存在,不能进行生产工单入库")
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail="工单产品不存在,不能进行生产工单入库")
|
||||
if not material:
|
||||
raise HTTPException(status_code=400, detail="领用材料不存在,不能进行生产工单入库")
|
||||
|
||||
snapshot = get_work_order_limit_snapshot(db, work_order_id)
|
||||
bom_item = _get_bom_item(db, work_order, issue.material_item_id)
|
||||
gross = to_decimal(bom_item.gross_weight_per_piece_kg or snapshot.gross_weight_per_piece_kg)
|
||||
net = to_decimal(bom_item.net_weight_per_piece_kg or snapshot.net_weight_per_piece_kg)
|
||||
if gross <= 0:
|
||||
gross = to_decimal(snapshot.gross_weight_per_piece_kg)
|
||||
if net <= 0:
|
||||
net = to_decimal(snapshot.net_weight_per_piece_kg)
|
||||
if gross <= 0:
|
||||
raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重,不能进行生产工单入库")
|
||||
if gross < net:
|
||||
net = gross
|
||||
|
||||
reference_finished_qty = _reference_finished_qty(to_decimal(snapshot.issued_weight_kg), gross)
|
||||
finished_received_qty = to_decimal(snapshot.finished_received_qty)
|
||||
remaining_reference_finished_qty = _non_negative(reference_finished_qty - finished_received_qty)
|
||||
selected_finished_qty = to_decimal(finished_qty) if finished_qty is not None else remaining_reference_finished_qty
|
||||
if selected_finished_qty < 0:
|
||||
raise HTTPException(status_code=400, detail="成品入库数量不能小于0")
|
||||
total_finished_qty = finished_received_qty + selected_finished_qty
|
||||
theoretical_surplus = _non_negative(
|
||||
to_decimal(snapshot.issued_weight_kg) - to_decimal(snapshot.returned_material_weight_kg) - total_finished_qty * gross
|
||||
)
|
||||
theoretical_scrap = _non_negative(to_decimal(snapshot.max_scrap_inbound_weight_kg))
|
||||
|
||||
return ProductionWorkOrderInboundPreviewRead(
|
||||
work_order_id=work_order.id,
|
||||
work_order_no=work_order.work_order_no,
|
||||
product_item_id=product.id,
|
||||
product_code=product.item_code,
|
||||
product_name=product.item_name,
|
||||
source_lot_id=source_lot.id,
|
||||
source_lot_no=source_lot.lot_no,
|
||||
material_item_id=material.id,
|
||||
material_code=material.item_code,
|
||||
material_name=material.item_name,
|
||||
gross_weight_per_piece_kg=float(_round_decimal(gross)),
|
||||
net_weight_per_piece_kg=float(_round_decimal(net)),
|
||||
issued_weight_kg=float(_round_decimal(to_decimal(snapshot.issued_weight_kg))),
|
||||
returned_weight_kg=float(_round_decimal(to_decimal(snapshot.returned_material_weight_kg))),
|
||||
final_operation_good_qty=float(_round_decimal(to_decimal(snapshot.final_operation_good_qty))),
|
||||
finished_inbound_qty=float(_round_decimal(to_decimal(snapshot.finished_received_qty))),
|
||||
reported_scrap_qty=float(_round_decimal(to_decimal(snapshot.total_operation_scrap_qty))),
|
||||
scrap_inbound_weight_kg=float(_round_decimal(to_decimal(snapshot.scrap_inbound_weight_kg))),
|
||||
reference_finished_qty=float(_round_decimal(reference_finished_qty)),
|
||||
remaining_reference_finished_qty=float(_round_decimal(remaining_reference_finished_qty)),
|
||||
finished_qty_upper_limit=float(_round_decimal(reference_finished_qty * FINISHED_QTY_UPPER_MULTIPLIER)),
|
||||
finished_qty=float(_round_decimal(selected_finished_qty)),
|
||||
theoretical_surplus_weight_kg=float(_round_decimal(theoretical_surplus)),
|
||||
theoretical_scrap_weight_kg=float(_round_decimal(theoretical_scrap)),
|
||||
)
|
||||
|
||||
|
||||
def create_production_work_order_inbound(
|
||||
db: Session,
|
||||
*,
|
||||
payload: ProductionWorkOrderInboundCreate,
|
||||
operator_user_id: int | None,
|
||||
) -> ProductionWorkOrderInboundRead:
|
||||
work_order = _get_work_order(db, payload.work_order_id)
|
||||
issue = _source_issue(db, payload.work_order_id)
|
||||
source_lot = db.get(StockLot, issue.source_lot_id)
|
||||
product = db.get(Item, work_order.product_item_id)
|
||||
material = db.get(Item, issue.material_item_id)
|
||||
if not source_lot or not product or not material:
|
||||
raise HTTPException(status_code=400, detail="工单入库来源数据不完整")
|
||||
|
||||
finished_qty = to_decimal(payload.finished_qty)
|
||||
surplus_weight = to_decimal(payload.surplus_weight_kg)
|
||||
scrap_weight = to_decimal(payload.scrap_weight_kg)
|
||||
if min(finished_qty, surplus_weight, scrap_weight) < 0:
|
||||
raise HTTPException(status_code=400, detail="入库数量或重量不能小于0")
|
||||
if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0:
|
||||
raise HTTPException(status_code=400, detail="生产工单入库至少需要填写成品、余料或废料中的一项")
|
||||
|
||||
preview = preview_production_work_order_inbound(db, work_order_id=payload.work_order_id, finished_qty=finished_qty)
|
||||
theoretical_surplus = to_decimal(preview.theoretical_surplus_weight_kg)
|
||||
theoretical_scrap = to_decimal(preview.theoretical_scrap_weight_kg)
|
||||
total_finished_qty = to_decimal(preview.finished_inbound_qty) + finished_qty
|
||||
reference_finished_qty = to_decimal(preview.reference_finished_qty)
|
||||
if _finished_qty_exceeds_reference_limit(total_finished_qty, reference_finished_qty) and not str(payload.deviation_remark or "").strip():
|
||||
raise HTTPException(status_code=400, detail="成品入库数量超过本工单参考生产数量上限15%,请填写偏差说明")
|
||||
if (
|
||||
_deviation_exceeds_limit(surplus_weight, theoretical_surplus)
|
||||
or _deviation_exceeds_limit(scrap_weight, theoretical_scrap)
|
||||
) and not str(payload.deviation_remark or "").strip():
|
||||
raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明")
|
||||
if payload.settle_work_order and not payload.settle_confirmed:
|
||||
raise HTTPException(status_code=400, detail="结单前必须确认本次入库明细及结单影响")
|
||||
|
||||
now = datetime.now()
|
||||
bom_item = _get_bom_item(db, work_order, issue.material_item_id)
|
||||
gross = to_decimal(bom_item.gross_weight_per_piece_kg or preview.gross_weight_per_piece_kg)
|
||||
product_weight = to_decimal(product.unit_weight_kg or bom_item.net_weight_per_piece_kg or preview.net_weight_per_piece_kg)
|
||||
finished_warehouse = _get_warehouse(db, "FINISHED") if finished_qty > 0 else None
|
||||
scrap_warehouse = _get_warehouse(db, "SCRAP") if scrap_weight > 0 else None
|
||||
material_row = db.get(WorkOrderMaterial, issue.work_order_material_id)
|
||||
if surplus_weight > 0 and not material_row:
|
||||
raise HTTPException(status_code=400, detail="工单领料明细不存在,不能进行余料入库")
|
||||
locked_check_warehouse_ids: set[int] = set()
|
||||
if finished_qty > 0 and finished_warehouse:
|
||||
locked_check_warehouse_ids.add(int(finished_warehouse.id))
|
||||
if surplus_weight > 0:
|
||||
locked_check_warehouse_ids.add(int(source_lot.warehouse_id))
|
||||
if scrap_weight > 0 and scrap_warehouse:
|
||||
locked_check_warehouse_ids.add(int(scrap_warehouse.id))
|
||||
ensure_warehouses_unlocked(db, locked_check_warehouse_ids, "生产工单入库")
|
||||
|
||||
finished_lot_id = surplus_lot_id = scrap_lot_id = None
|
||||
finished_txn_id = surplus_txn_id = scrap_txn_id = None
|
||||
document_batch_no = _production_document_batch_no(now)
|
||||
|
||||
if finished_qty > 0 and finished_warehouse:
|
||||
receipt = CompletionReceipt(
|
||||
receipt_no=_build_receipt_no(db, work_order, now),
|
||||
work_order_id=work_order.id,
|
||||
warehouse_id=finished_warehouse.id,
|
||||
receipt_time=now,
|
||||
receiver_employee_id=None,
|
||||
status="POSTED",
|
||||
remark=_remark_with_deviation(payload.remark or "生产工单入库", payload.deviation_remark),
|
||||
)
|
||||
db.add(receipt)
|
||||
db.flush()
|
||||
lot_no = _build_lot_no(db, "FGI", work_order, product, now)
|
||||
receipt_weight = finished_qty * product_weight
|
||||
unit_cost = to_decimal(to_decimal(source_lot.unit_cost) * gross, "0.0001")
|
||||
material_summary = f"{material.item_name}:{source_lot.lot_no}"
|
||||
receipt_item = CompletionReceiptItem(
|
||||
completion_receipt_id=receipt.id,
|
||||
line_no=1,
|
||||
product_item_id=product.id,
|
||||
location_id=None,
|
||||
lot_no=lot_no,
|
||||
source_material_lot_id=source_lot.id,
|
||||
source_material_sub_batch_no=issue.material_sub_batch_no,
|
||||
source_material_summary=material_summary,
|
||||
receipt_qty=finished_qty,
|
||||
receipt_weight_kg=receipt_weight,
|
||||
unit_cost=unit_cost,
|
||||
status="POSTED",
|
||||
remark=_remark_with_deviation(payload.remark or "生产工单入库", payload.deviation_remark),
|
||||
)
|
||||
db.add(receipt_item)
|
||||
db.flush()
|
||||
finished_lot = StockLot(
|
||||
lot_no=lot_no,
|
||||
parent_lot_id=source_lot.id,
|
||||
lot_role="FINISHED_FROM_RAW_BATCH",
|
||||
material_sub_batch_no=None,
|
||||
item_id=product.id,
|
||||
warehouse_id=finished_warehouse.id,
|
||||
location_id=None,
|
||||
source_doc_type="FG_RECEIPT",
|
||||
source_doc_id=receipt.id,
|
||||
source_line_id=receipt_item.id,
|
||||
source_material_lot_id=source_lot.id,
|
||||
source_material_sub_batch_no=issue.material_sub_batch_no,
|
||||
source_material_summary=material_summary,
|
||||
inbound_qty=finished_qty,
|
||||
inbound_weight_kg=receipt_weight,
|
||||
remaining_qty=finished_qty,
|
||||
remaining_weight_kg=receipt_weight,
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=unit_cost,
|
||||
production_date=now.date(),
|
||||
expire_date=None,
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
remark=_with_document_batch_no(
|
||||
_remark_with_deviation("生产工单入库;来源库存批次已记录", payload.deviation_remark),
|
||||
document_batch_no,
|
||||
),
|
||||
)
|
||||
db.add(finished_lot)
|
||||
db.flush()
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=product.id,
|
||||
warehouse_id=finished_warehouse.id,
|
||||
location_id=None,
|
||||
qty_delta=finished_qty,
|
||||
weight_delta=receipt_weight,
|
||||
available_qty_delta=finished_qty,
|
||||
available_weight_delta=receipt_weight,
|
||||
unit_cost=unit_cost,
|
||||
)
|
||||
txn = create_inventory_txn(
|
||||
db,
|
||||
txn_type="FG_IN",
|
||||
item_id=product.id,
|
||||
warehouse_id=finished_warehouse.id,
|
||||
location_id=None,
|
||||
lot_id=finished_lot.id,
|
||||
qty_change=finished_qty,
|
||||
weight_change=receipt_weight,
|
||||
unit_cost=unit_cost,
|
||||
source_doc_type="FG_RECEIPT",
|
||||
source_doc_id=receipt.id,
|
||||
source_line_id=receipt_item.id,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=_with_document_batch_no(_remark_with_deviation("生产工单入库", payload.deviation_remark), document_batch_no),
|
||||
amount_basis="QTY",
|
||||
)
|
||||
finished_lot_id = finished_lot.id
|
||||
finished_txn_id = txn.id
|
||||
work_order.finished_qty = to_decimal(work_order.finished_qty) + finished_qty
|
||||
|
||||
if surplus_weight > 0:
|
||||
source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight
|
||||
source_lot.status = "AVAILABLE"
|
||||
db.add(source_lot)
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=material.id,
|
||||
warehouse_id=source_lot.warehouse_id,
|
||||
location_id=source_lot.location_id,
|
||||
qty_delta=Decimal("0"),
|
||||
weight_delta=surplus_weight,
|
||||
available_qty_delta=Decimal("0"),
|
||||
available_weight_delta=surplus_weight,
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
)
|
||||
txn = create_inventory_txn(
|
||||
db,
|
||||
txn_type="PRODUCTION_SURPLUS_IN",
|
||||
item_id=material.id,
|
||||
warehouse_id=source_lot.warehouse_id,
|
||||
location_id=source_lot.location_id,
|
||||
lot_id=source_lot.id,
|
||||
qty_change=Decimal("0"),
|
||||
weight_change=surplus_weight,
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
source_doc_type="WORK_ORDER",
|
||||
source_doc_id=work_order.id,
|
||||
source_line_id=material_row.id if material_row else None,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=_with_document_batch_no(_remark_with_deviation("生产余料入库", payload.deviation_remark), document_batch_no),
|
||||
amount_basis="WEIGHT",
|
||||
)
|
||||
if material_row:
|
||||
material_row.returned_weight_kg = to_decimal(material_row.returned_weight_kg) + surplus_weight
|
||||
db.add(material_row)
|
||||
surplus_lot_id = source_lot.id
|
||||
surplus_txn_id = txn.id
|
||||
|
||||
if scrap_weight > 0 and scrap_warehouse:
|
||||
scrap_lot_no = _build_lot_no(db, "SCRAPI", work_order, material, now)
|
||||
scrap_lot = StockLot(
|
||||
lot_no=scrap_lot_no,
|
||||
parent_lot_id=source_lot.id,
|
||||
lot_role="PRODUCTION_SCRAP",
|
||||
material_sub_batch_no=None,
|
||||
item_id=material.id,
|
||||
warehouse_id=scrap_warehouse.id,
|
||||
location_id=None,
|
||||
source_doc_type="PRODUCTION_SCRAP_IN",
|
||||
source_doc_id=work_order.id,
|
||||
source_line_id=None,
|
||||
source_material_lot_id=source_lot.id,
|
||||
source_material_sub_batch_no=issue.material_sub_batch_no,
|
||||
source_material_summary=f"{material.item_name}:{source_lot.lot_no}",
|
||||
inbound_qty=Decimal("0"),
|
||||
inbound_weight_kg=scrap_weight,
|
||||
remaining_qty=Decimal("0"),
|
||||
remaining_weight_kg=scrap_weight,
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
production_date=now.date(),
|
||||
expire_date=None,
|
||||
quality_status="SCRAP",
|
||||
status="AVAILABLE",
|
||||
remark=_with_document_batch_no(
|
||||
_remark_with_deviation("生产废料入库;来源库存批次已记录", payload.deviation_remark),
|
||||
document_batch_no,
|
||||
),
|
||||
)
|
||||
db.add(scrap_lot)
|
||||
db.flush()
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=material.id,
|
||||
warehouse_id=scrap_warehouse.id,
|
||||
location_id=None,
|
||||
qty_delta=Decimal("0"),
|
||||
weight_delta=scrap_weight,
|
||||
available_qty_delta=Decimal("0"),
|
||||
available_weight_delta=scrap_weight,
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
)
|
||||
txn = create_inventory_txn(
|
||||
db,
|
||||
txn_type="PRODUCTION_SCRAP_IN",
|
||||
item_id=material.id,
|
||||
warehouse_id=scrap_warehouse.id,
|
||||
location_id=None,
|
||||
lot_id=scrap_lot.id,
|
||||
qty_change=Decimal("0"),
|
||||
weight_change=scrap_weight,
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
source_doc_type="WORK_ORDER",
|
||||
source_doc_id=work_order.id,
|
||||
source_line_id=None,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=_with_document_batch_no(_remark_with_deviation("生产废料入库", payload.deviation_remark), document_batch_no),
|
||||
amount_basis="WEIGHT",
|
||||
)
|
||||
scrap_lot_id = scrap_lot.id
|
||||
scrap_txn_id = txn.id
|
||||
|
||||
settled = False
|
||||
if payload.settle_work_order:
|
||||
work_order.status = "SETTLED"
|
||||
work_order.actual_end_time = work_order.actual_end_time or now
|
||||
_append_settle_remark(work_order, payload.deviation_remark, payload.remark)
|
||||
settled = True
|
||||
response_work_order_id = int(work_order.id)
|
||||
response_work_order_no = str(work_order.work_order_no)
|
||||
response_status = str(work_order.status)
|
||||
response_remark = work_order.remark
|
||||
main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
|
||||
db.add(work_order)
|
||||
db.commit()
|
||||
|
||||
archive_status = "未生成"
|
||||
archive_business_id = int(main_txn_id) if main_txn_id else None
|
||||
archive_document_type = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if main_txn_id else None
|
||||
archive_error_message = None
|
||||
if main_txn_id:
|
||||
try:
|
||||
archive_result = generate_document_archive(
|
||||
db,
|
||||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||||
int(main_txn_id),
|
||||
created_by=operator_user_id,
|
||||
)
|
||||
archive_status = archive_result.archive_status
|
||||
archive_error_message = archive_result.archive_error_message
|
||||
except Exception as exc:
|
||||
_ = exc
|
||||
archive_status = "归档失败"
|
||||
archive_error_message = "归档失败:PDF归档服务异常,请稍后重新生成"
|
||||
|
||||
return ProductionWorkOrderInboundRead(
|
||||
work_order_id=response_work_order_id,
|
||||
work_order_no=response_work_order_no,
|
||||
finished_qty=float(_round_decimal(finished_qty)),
|
||||
surplus_weight_kg=float(_round_decimal(surplus_weight)),
|
||||
scrap_weight_kg=float(_round_decimal(scrap_weight)),
|
||||
finished_lot_id=finished_lot_id,
|
||||
surplus_lot_id=surplus_lot_id,
|
||||
scrap_lot_id=scrap_lot_id,
|
||||
finished_txn_id=finished_txn_id,
|
||||
surplus_txn_id=surplus_txn_id,
|
||||
scrap_txn_id=scrap_txn_id,
|
||||
settled=settled,
|
||||
status=response_status,
|
||||
remark=response_remark,
|
||||
archive_status=archive_status,
|
||||
archive_business_id=archive_business_id,
|
||||
archive_document_type=archive_document_type,
|
||||
archive_error_message=archive_error_message,
|
||||
)
|
||||
@@ -0,0 +1,356 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import BomItem, Item
|
||||
from app.models.operations import (
|
||||
CompletionReceipt,
|
||||
CompletionReceiptItem,
|
||||
InventoryTxn,
|
||||
StockLot,
|
||||
WorkOrder,
|
||||
WorkOrderMaterial,
|
||||
WorkOrderMaterialIssue,
|
||||
WorkOrderOperation,
|
||||
)
|
||||
from app.services.operations import to_decimal
|
||||
|
||||
|
||||
INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15")
|
||||
|
||||
|
||||
class WorkOrderLimitSnapshot(BaseModel):
|
||||
work_order_id: int
|
||||
gross_weight_per_piece_kg: Decimal
|
||||
net_weight_per_piece_kg: Decimal
|
||||
issued_weight_kg: Decimal
|
||||
used_material_weight_kg: Decimal
|
||||
returned_material_weight_kg: Decimal
|
||||
final_operation_good_qty: Decimal
|
||||
finished_received_qty: Decimal
|
||||
total_operation_scrap_qty: Decimal
|
||||
scrap_inbound_weight_kg: Decimal
|
||||
standard_scrap_weight_kg: Decimal
|
||||
max_finished_inbound_qty: Decimal
|
||||
max_scrap_inbound_weight_kg: Decimal
|
||||
max_surplus_inbound_weight_kg: Decimal
|
||||
tolerance_finished_inbound_qty: Decimal
|
||||
tolerance_scrap_inbound_weight_kg: Decimal
|
||||
tolerance_surplus_inbound_weight_kg: Decimal
|
||||
|
||||
|
||||
class WorkOrderLedgerMaterialRow(BaseModel):
|
||||
material_item_id: int
|
||||
material_code: str
|
||||
material_name: str
|
||||
source_lot_id: int
|
||||
source_lot_no: str
|
||||
issued_weight_kg: Decimal
|
||||
returned_weight_kg: Decimal = Decimal("0")
|
||||
unit_cost: Decimal = Decimal("0")
|
||||
amount: Decimal = Decimal("0")
|
||||
|
||||
|
||||
class WorkOrderLedgerOperationRow(BaseModel):
|
||||
work_order_operation_id: int
|
||||
seq_no: int
|
||||
operation_name: str
|
||||
report_qty: Decimal
|
||||
good_qty: Decimal
|
||||
scrap_qty: Decimal
|
||||
status: str
|
||||
|
||||
|
||||
class WorkOrderLedgerRow(BaseModel):
|
||||
work_order_id: int
|
||||
work_order_no: str
|
||||
product_item_id: int
|
||||
product_code: str
|
||||
product_name: str
|
||||
planned_qty: Decimal
|
||||
status: str
|
||||
remark: str | None = None
|
||||
material_summary: str
|
||||
source_lot_summary: str
|
||||
issued_weight_kg: Decimal
|
||||
used_material_weight_kg: Decimal
|
||||
returned_material_weight_kg: Decimal
|
||||
finished_received_qty: Decimal
|
||||
max_finished_inbound_qty: Decimal
|
||||
max_scrap_inbound_weight_kg: Decimal
|
||||
max_surplus_inbound_weight_kg: Decimal
|
||||
limits: WorkOrderLimitSnapshot
|
||||
materials: list[WorkOrderLedgerMaterialRow]
|
||||
operations: list[WorkOrderLedgerOperationRow]
|
||||
|
||||
|
||||
def _non_negative(value: Decimal) -> Decimal:
|
||||
return max(value, Decimal("0"))
|
||||
|
||||
|
||||
def _round_decimal(value: Decimal, places: str = "0.001") -> Decimal:
|
||||
return to_decimal(value, places)
|
||||
|
||||
|
||||
def _get_work_order(db: Session, work_order_id: int) -> WorkOrder:
|
||||
work_order = db.get(WorkOrder, work_order_id)
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=404, detail="生产工单不存在")
|
||||
return work_order
|
||||
|
||||
|
||||
def _get_weight_profile(db: Session, work_order: WorkOrder) -> tuple[Decimal, Decimal]:
|
||||
bom_item = db.scalar(
|
||||
select(BomItem)
|
||||
.where(
|
||||
BomItem.bom_id == work_order.bom_id,
|
||||
BomItem.gross_weight_per_piece_kg > 0,
|
||||
)
|
||||
.order_by(BomItem.seq_no, BomItem.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not bom_item:
|
||||
bom_item = db.scalar(
|
||||
select(BomItem)
|
||||
.where(BomItem.bom_id == work_order.bom_id)
|
||||
.order_by(BomItem.seq_no, BomItem.id)
|
||||
.limit(1)
|
||||
)
|
||||
product = db.get(Item, work_order.product_item_id)
|
||||
gross = to_decimal(bom_item.gross_weight_per_piece_kg if bom_item else 0)
|
||||
net = to_decimal(bom_item.net_weight_per_piece_kg if bom_item else 0)
|
||||
if net <= 0 and product:
|
||||
net = to_decimal(product.unit_weight_kg)
|
||||
if gross <= 0:
|
||||
gross = net
|
||||
if gross <= 0:
|
||||
raise HTTPException(status_code=400, detail="产品需用清单缺少产品毛重,无法计算工单入库上限")
|
||||
if net < 0:
|
||||
net = Decimal("0")
|
||||
if gross < net:
|
||||
gross = net
|
||||
return gross, net
|
||||
|
||||
|
||||
def _get_operations(db: Session, work_order_id: int) -> list[WorkOrderOperation]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(WorkOrderOperation)
|
||||
.where(WorkOrderOperation.work_order_id == work_order_id)
|
||||
.order_by(WorkOrderOperation.seq_no, WorkOrderOperation.id)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _get_finished_received_qty(db: Session, work_order_id: int) -> Decimal:
|
||||
value = db.scalar(
|
||||
select(func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0))
|
||||
.join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id)
|
||||
.where(CompletionReceipt.work_order_id == work_order_id)
|
||||
)
|
||||
return to_decimal(value)
|
||||
|
||||
|
||||
def _get_scrap_inbound_weight(db: Session, work_order_id: int) -> Decimal:
|
||||
value = db.scalar(
|
||||
select(func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0)).where(
|
||||
InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN",
|
||||
InventoryTxn.source_doc_type.in_(("WORK_ORDER", "PRODUCTION_SCRAP_IN")),
|
||||
InventoryTxn.source_doc_id == work_order_id,
|
||||
)
|
||||
)
|
||||
return _non_negative(to_decimal(value))
|
||||
|
||||
|
||||
def get_work_order_limit_snapshot(db: Session, work_order_id: int) -> WorkOrderLimitSnapshot:
|
||||
work_order = _get_work_order(db, work_order_id)
|
||||
gross_weight, net_weight = _get_weight_profile(db, work_order)
|
||||
operations = _get_operations(db, work_order_id)
|
||||
issued_weight = to_decimal(
|
||||
db.scalar(
|
||||
select(func.coalesce(func.sum(WorkOrderMaterialIssue.issued_weight_kg), 0)).where(
|
||||
WorkOrderMaterialIssue.work_order_id == work_order_id
|
||||
)
|
||||
)
|
||||
)
|
||||
returned_weight = to_decimal(
|
||||
db.scalar(
|
||||
select(func.coalesce(func.sum(WorkOrderMaterial.returned_weight_kg), 0)).where(
|
||||
WorkOrderMaterial.work_order_id == work_order_id
|
||||
)
|
||||
)
|
||||
)
|
||||
max_report_qty = max((to_decimal(operation.report_qty) for operation in operations), default=Decimal("0"))
|
||||
used_material_weight = _round_decimal(max_report_qty * gross_weight)
|
||||
final_good_qty = to_decimal(operations[-1].good_qty) if operations else Decimal("0")
|
||||
finished_received_qty = _get_finished_received_qty(db, work_order_id)
|
||||
total_scrap_qty = sum((to_decimal(operation.scrap_qty) for operation in operations), Decimal("0"))
|
||||
scrap_inbound_weight = _get_scrap_inbound_weight(db, work_order_id)
|
||||
edge_scrap_weight = _non_negative((max_report_qty - total_scrap_qty) * _non_negative(gross_weight - net_weight))
|
||||
rejected_product_scrap_weight = total_scrap_qty * gross_weight
|
||||
standard_scrap_weight = edge_scrap_weight + rejected_product_scrap_weight
|
||||
max_finished_inbound_qty = _non_negative(final_good_qty - finished_received_qty)
|
||||
max_scrap_inbound_weight = _non_negative(standard_scrap_weight - scrap_inbound_weight)
|
||||
max_surplus_inbound_weight = _non_negative(issued_weight - used_material_weight - returned_weight)
|
||||
tolerance_scrap_inbound_weight = _non_negative(
|
||||
standard_scrap_weight * INBOUND_TOLERANCE_MULTIPLIER - scrap_inbound_weight
|
||||
)
|
||||
tolerance_surplus_inbound_weight = _non_negative(
|
||||
(issued_weight - used_material_weight) * INBOUND_TOLERANCE_MULTIPLIER - returned_weight
|
||||
)
|
||||
|
||||
return WorkOrderLimitSnapshot(
|
||||
work_order_id=work_order_id,
|
||||
gross_weight_per_piece_kg=_round_decimal(gross_weight),
|
||||
net_weight_per_piece_kg=_round_decimal(net_weight),
|
||||
issued_weight_kg=_round_decimal(issued_weight),
|
||||
used_material_weight_kg=_round_decimal(used_material_weight),
|
||||
returned_material_weight_kg=_round_decimal(returned_weight),
|
||||
final_operation_good_qty=_round_decimal(final_good_qty),
|
||||
finished_received_qty=_round_decimal(finished_received_qty),
|
||||
total_operation_scrap_qty=_round_decimal(total_scrap_qty),
|
||||
scrap_inbound_weight_kg=_round_decimal(scrap_inbound_weight),
|
||||
standard_scrap_weight_kg=_round_decimal(standard_scrap_weight),
|
||||
max_finished_inbound_qty=_round_decimal(max_finished_inbound_qty),
|
||||
max_scrap_inbound_weight_kg=_round_decimal(max_scrap_inbound_weight),
|
||||
max_surplus_inbound_weight_kg=_round_decimal(max_surplus_inbound_weight),
|
||||
tolerance_finished_inbound_qty=_round_decimal(max_finished_inbound_qty * INBOUND_TOLERANCE_MULTIPLIER),
|
||||
tolerance_scrap_inbound_weight_kg=_round_decimal(tolerance_scrap_inbound_weight),
|
||||
tolerance_surplus_inbound_weight_kg=_round_decimal(tolerance_surplus_inbound_weight),
|
||||
)
|
||||
|
||||
|
||||
def validate_inbound_with_tolerance(label: str, requested: Decimal, calculated_max: Decimal) -> None:
|
||||
if requested <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"{label}必须大于0")
|
||||
allowed = _round_decimal(_non_negative(calculated_max) * INBOUND_TOLERANCE_MULTIPLIER)
|
||||
if requested > allowed:
|
||||
raise HTTPException(status_code=400, detail=f"{label}超过允许入库上限 {allowed}")
|
||||
|
||||
|
||||
def validate_work_order_source_lot(
|
||||
db: Session,
|
||||
work_order_id: int,
|
||||
source_lot_id: int,
|
||||
item_id: int,
|
||||
) -> WorkOrderMaterial:
|
||||
row = db.execute(
|
||||
select(WorkOrderMaterial)
|
||||
.join(WorkOrderMaterialIssue, WorkOrderMaterialIssue.work_order_material_id == WorkOrderMaterial.id)
|
||||
.where(
|
||||
WorkOrderMaterial.work_order_id == work_order_id,
|
||||
WorkOrderMaterial.material_item_id == item_id,
|
||||
WorkOrderMaterialIssue.source_lot_id == source_lot_id,
|
||||
)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=400, detail="来源库存批次号不属于该生产工单")
|
||||
return row
|
||||
|
||||
|
||||
def get_work_order_ledger_rows(
|
||||
db: Session,
|
||||
limit: int = 200,
|
||||
work_order_id: int | None = None,
|
||||
) -> list[WorkOrderLedgerRow]:
|
||||
stmt = select(WorkOrder).order_by(WorkOrder.id.desc()).limit(limit)
|
||||
if work_order_id:
|
||||
stmt = stmt.where(WorkOrder.id == work_order_id)
|
||||
work_orders = db.scalars(stmt).all()
|
||||
rows: list[WorkOrderLedgerRow] = []
|
||||
|
||||
for work_order in work_orders:
|
||||
product = db.get(Item, work_order.product_item_id)
|
||||
limits = get_work_order_limit_snapshot(db, work_order.id)
|
||||
surplus_return_subquery = (
|
||||
select(
|
||||
InventoryTxn.lot_id.label("source_lot_id"),
|
||||
func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0).label("returned_weight_kg"),
|
||||
)
|
||||
.where(
|
||||
InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN",
|
||||
InventoryTxn.source_doc_type == "WORK_ORDER",
|
||||
InventoryTxn.source_doc_id == work_order.id,
|
||||
)
|
||||
.group_by(InventoryTxn.lot_id)
|
||||
.subquery()
|
||||
)
|
||||
issue_rows = db.execute(
|
||||
select(
|
||||
WorkOrderMaterialIssue.material_item_id,
|
||||
Item.item_code,
|
||||
Item.item_name,
|
||||
WorkOrderMaterialIssue.source_lot_id,
|
||||
StockLot.lot_no,
|
||||
WorkOrderMaterialIssue.issued_weight_kg,
|
||||
func.coalesce(surplus_return_subquery.c.returned_weight_kg, 0).label("returned_weight_kg"),
|
||||
WorkOrderMaterialIssue.unit_cost,
|
||||
WorkOrderMaterialIssue.amount,
|
||||
)
|
||||
.join(Item, Item.id == WorkOrderMaterialIssue.material_item_id)
|
||||
.join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
|
||||
.join(WorkOrderMaterial, WorkOrderMaterial.id == WorkOrderMaterialIssue.work_order_material_id)
|
||||
.outerjoin(surplus_return_subquery, surplus_return_subquery.c.source_lot_id == WorkOrderMaterialIssue.source_lot_id)
|
||||
.where(WorkOrderMaterialIssue.work_order_id == work_order.id)
|
||||
.order_by(WorkOrderMaterialIssue.id)
|
||||
).mappings().all()
|
||||
material_rows = [
|
||||
WorkOrderLedgerMaterialRow(
|
||||
material_item_id=int(issue["material_item_id"]),
|
||||
material_code=issue["item_code"],
|
||||
material_name=issue["item_name"],
|
||||
source_lot_id=int(issue["source_lot_id"]),
|
||||
source_lot_no=issue["lot_no"],
|
||||
issued_weight_kg=_round_decimal(to_decimal(issue["issued_weight_kg"])),
|
||||
returned_weight_kg=_round_decimal(to_decimal(issue["returned_weight_kg"])),
|
||||
unit_cost=_round_decimal(to_decimal(issue["unit_cost"]), "0.0001"),
|
||||
amount=_round_decimal(to_decimal(issue["amount"]), "0.01"),
|
||||
)
|
||||
for issue in issue_rows
|
||||
]
|
||||
operation_rows = [
|
||||
WorkOrderLedgerOperationRow(
|
||||
work_order_operation_id=int(operation.id),
|
||||
seq_no=int(operation.seq_no),
|
||||
operation_name=operation.operation_name,
|
||||
report_qty=_round_decimal(to_decimal(operation.report_qty)),
|
||||
good_qty=_round_decimal(to_decimal(operation.good_qty)),
|
||||
scrap_qty=_round_decimal(to_decimal(operation.scrap_qty)),
|
||||
status=operation.status,
|
||||
)
|
||||
for operation in _get_operations(db, work_order.id)
|
||||
]
|
||||
material_names = sorted({row.material_name for row in material_rows})
|
||||
source_lot_nos = [row.source_lot_no for row in material_rows]
|
||||
rows.append(
|
||||
WorkOrderLedgerRow(
|
||||
work_order_id=int(work_order.id),
|
||||
work_order_no=work_order.work_order_no,
|
||||
product_item_id=int(work_order.product_item_id),
|
||||
product_code=product.item_code if product else "",
|
||||
product_name=product.item_name if product else "",
|
||||
planned_qty=_round_decimal(to_decimal(work_order.planned_qty)),
|
||||
status=work_order.status,
|
||||
remark=work_order.remark,
|
||||
material_summary="、".join(material_names) if material_names else "-",
|
||||
source_lot_summary="、".join(source_lot_nos) if source_lot_nos else "-",
|
||||
issued_weight_kg=limits.issued_weight_kg,
|
||||
used_material_weight_kg=limits.used_material_weight_kg,
|
||||
returned_material_weight_kg=limits.returned_material_weight_kg,
|
||||
finished_received_qty=limits.finished_received_qty,
|
||||
max_finished_inbound_qty=limits.max_finished_inbound_qty,
|
||||
max_scrap_inbound_weight_kg=limits.max_scrap_inbound_weight_kg,
|
||||
max_surplus_inbound_weight_kg=limits.max_surplus_inbound_weight_kg,
|
||||
limits=limits,
|
||||
materials=material_rows,
|
||||
operations=operation_rows,
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import inspect, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.operations import PurchaseOrderItem, PurchaseOrderSalesOrderLink
|
||||
from app.models.planning import MaterialDemand
|
||||
from app.models.sales import SalesOrder, SalesOrderItem
|
||||
|
||||
|
||||
def ensure_purchase_order_sales_link_table(db: Session) -> None:
|
||||
"""Keep the explicit purchase-sales link table available when DB patches lag behind code."""
|
||||
if db.info.get("purchase_order_sales_link_table_ensured"):
|
||||
return
|
||||
bind = db.connection()
|
||||
inspector = inspect(bind)
|
||||
existed = inspector.has_table(PurchaseOrderSalesOrderLink.__tablename__)
|
||||
PurchaseOrderSalesOrderLink.__table__.create(bind=bind, checkfirst=True)
|
||||
if not existed:
|
||||
backfill_purchase_order_sales_links(db)
|
||||
db.info["purchase_order_sales_link_table_ensured"] = True
|
||||
|
||||
|
||||
def backfill_purchase_order_sales_links(db: Session) -> None:
|
||||
existing_pairs = {
|
||||
(int(row.purchase_order_id), int(row.sales_order_id))
|
||||
for row in db.execute(
|
||||
select(PurchaseOrderSalesOrderLink.purchase_order_id, PurchaseOrderSalesOrderLink.sales_order_id)
|
||||
)
|
||||
}
|
||||
inferred_rows = db.execute(
|
||||
select(
|
||||
PurchaseOrderItem.purchase_order_id.label("purchase_order_id"),
|
||||
SalesOrder.id.label("sales_order_id"),
|
||||
)
|
||||
.join(MaterialDemand, MaterialDemand.id == PurchaseOrderItem.source_demand_id)
|
||||
.join(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id)
|
||||
.join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id)
|
||||
.distinct()
|
||||
).all()
|
||||
now = datetime.now()
|
||||
for row in inferred_rows:
|
||||
pair = (int(row.purchase_order_id), int(row.sales_order_id))
|
||||
if pair in existing_pairs:
|
||||
continue
|
||||
db.add(
|
||||
PurchaseOrderSalesOrderLink(
|
||||
purchase_order_id=pair[0],
|
||||
sales_order_id=pair[1],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
existing_pairs.add(pair)
|
||||
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, ROUND_UP
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Select, and_, case, delete, func, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance
|
||||
from app.models.document_archive import DocumentArchive
|
||||
from app.models.operations import Delivery, DeliveryItem, PurchaseOrderItem, WorkOrder
|
||||
from app.models.org import Employee
|
||||
from app.models.planning import MaterialDemand
|
||||
from app.models.sales import Customer, SalesOrder, SalesOrderItem
|
||||
from app.schemas.database import MaterialDemandRead
|
||||
|
||||
|
||||
def build_serial(prefix: str) -> str:
|
||||
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S%f')[-12:]}"
|
||||
|
||||
|
||||
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 decimalize(value: float | Decimal, places: str = "0.000001") -> Decimal:
|
||||
return Decimal(str(value)).quantize(Decimal(places))
|
||||
|
||||
|
||||
def round_up_by_multiple(value: Decimal, multiple: Decimal) -> Decimal:
|
||||
if multiple <= 0:
|
||||
return decimalize(value)
|
||||
return ((value / multiple).quantize(Decimal("1"), rounding=ROUND_UP) * multiple).quantize(Decimal("0.000001"))
|
||||
|
||||
|
||||
def create_customer_code(db: Session) -> str:
|
||||
date_part = date.today().strftime("%Y%m%d")
|
||||
return next_doc_no(db, Customer, "customer_code", f"CUS-NB-{date_part}")
|
||||
|
||||
|
||||
def build_mrp_demand_no(db: Session, sales_order: SalesOrder, order_item: SalesOrderItem, material_item: Item) -> str:
|
||||
date_part = date.today().strftime("%Y%m%d")
|
||||
order_part = code_token(sales_order.order_no, f"SO{sales_order.id:04d}", 12)
|
||||
material_part = code_token(material_item.item_code or material_item.item_name, f"RM{material_item.id:04d}", 10)
|
||||
prefix = f"MRP-NB-{date_part}-{order_part}-L{int(order_item.line_no or 0):02d}-{material_part}"
|
||||
return next_doc_no(db, MaterialDemand, "demand_no", prefix)
|
||||
|
||||
|
||||
def get_sales_order_query(limit: int = 50) -> Select:
|
||||
sales_employee = aliased(Employee)
|
||||
line_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(SalesOrderItem.id).label("line_count"),
|
||||
)
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
mrp_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(MaterialDemand.id).label("mrp_count"),
|
||||
)
|
||||
.join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id)
|
||||
.where(MaterialDemand.status != "SUPERSEDED")
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
purchase_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(func.distinct(PurchaseOrderItem.purchase_order_id)).label("purchase_order_count"),
|
||||
)
|
||||
.join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id)
|
||||
.join(PurchaseOrderItem, PurchaseOrderItem.source_demand_id == MaterialDemand.id)
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
delivery_count_subquery = (
|
||||
select(
|
||||
Delivery.sales_order_id.label("sales_order_id"),
|
||||
func.count(Delivery.id).label("delivery_count"),
|
||||
)
|
||||
.where(Delivery.sales_order_id.is_not(None))
|
||||
.group_by(Delivery.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
delivery_item_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(DeliveryItem.id).label("delivery_item_count"),
|
||||
)
|
||||
.join(DeliveryItem, DeliveryItem.sales_order_item_id == SalesOrderItem.id)
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
work_order_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(WorkOrder.id).label("work_order_count"),
|
||||
)
|
||||
.join(WorkOrder, WorkOrder.source_sales_order_item_id == SalesOrderItem.id)
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_archive_version_subquery = (
|
||||
select(
|
||||
DocumentArchive.business_id.label("business_id"),
|
||||
func.max(DocumentArchive.archive_version).label("archive_version"),
|
||||
)
|
||||
.where(DocumentArchive.document_type == "销售订单", DocumentArchive.file_format == "PDF")
|
||||
.group_by(DocumentArchive.business_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_archive_id_subquery = (
|
||||
select(
|
||||
DocumentArchive.business_id.label("business_id"),
|
||||
func.max(DocumentArchive.id).label("archive_id"),
|
||||
)
|
||||
.join(
|
||||
latest_archive_version_subquery,
|
||||
and_(
|
||||
latest_archive_version_subquery.c.business_id == DocumentArchive.business_id,
|
||||
latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version,
|
||||
),
|
||||
)
|
||||
.where(DocumentArchive.document_type == "销售订单", DocumentArchive.file_format == "PDF")
|
||||
.group_by(DocumentArchive.business_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_archive_subquery = (
|
||||
select(
|
||||
DocumentArchive.business_id.label("business_id"),
|
||||
DocumentArchive.status.label("archive_status"),
|
||||
DocumentArchive.archive_version.label("archive_version"),
|
||||
DocumentArchive.created_at.label("archive_created_at"),
|
||||
DocumentArchive.error_message.label("archive_error_message"),
|
||||
)
|
||||
.join(
|
||||
latest_archive_id_subquery,
|
||||
latest_archive_id_subquery.c.archive_id == DocumentArchive.id,
|
||||
)
|
||||
.where(DocumentArchive.document_type == "销售订单", DocumentArchive.file_format == "PDF")
|
||||
.subquery()
|
||||
)
|
||||
|
||||
return (
|
||||
select(
|
||||
SalesOrder.id.label("order_id"),
|
||||
SalesOrder.order_no.label("order_no"),
|
||||
SalesOrder.customer_id.label("customer_id"),
|
||||
Customer.customer_code.label("customer_code"),
|
||||
Customer.customer_name.label("customer_name"),
|
||||
SalesOrder.sales_employee_id.label("sales_employee_id"),
|
||||
sales_employee.employee_name.label("sales_employee_name"),
|
||||
SalesOrder.order_date.label("order_date"),
|
||||
SalesOrder.promised_date.label("promised_date"),
|
||||
SalesOrder.delivery_address.label("delivery_address"),
|
||||
SalesOrder.total_amount.label("total_amount"),
|
||||
SalesOrder.status.label("status"),
|
||||
func.coalesce(line_count_subquery.c.line_count, 0).label("line_count"),
|
||||
func.coalesce(mrp_count_subquery.c.mrp_count, 0).label("mrp_count"),
|
||||
func.coalesce(purchase_count_subquery.c.purchase_order_count, 0).label("purchase_order_count"),
|
||||
func.coalesce(delivery_count_subquery.c.delivery_count, 0).label("delivery_count"),
|
||||
func.coalesce(delivery_item_count_subquery.c.delivery_item_count, 0).label("delivery_item_count"),
|
||||
func.coalesce(work_order_count_subquery.c.work_order_count, 0).label("work_order_count"),
|
||||
func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"),
|
||||
latest_archive_subquery.c.archive_version.label("archive_version"),
|
||||
latest_archive_subquery.c.archive_created_at.label("archive_created_at"),
|
||||
latest_archive_subquery.c.archive_error_message.label("archive_error_message"),
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
func.coalesce(purchase_count_subquery.c.purchase_order_count, 0) == 0,
|
||||
func.coalesce(delivery_count_subquery.c.delivery_count, 0) == 0,
|
||||
func.coalesce(delivery_item_count_subquery.c.delivery_item_count, 0) == 0,
|
||||
func.coalesce(work_order_count_subquery.c.work_order_count, 0) == 0,
|
||||
),
|
||||
True,
|
||||
),
|
||||
else_=False,
|
||||
).label("can_delete"),
|
||||
)
|
||||
.join(Customer, Customer.id == SalesOrder.customer_id)
|
||||
.outerjoin(sales_employee, sales_employee.id == SalesOrder.sales_employee_id)
|
||||
.outerjoin(line_count_subquery, line_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(mrp_count_subquery, mrp_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(purchase_count_subquery, purchase_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(delivery_count_subquery, delivery_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(delivery_item_count_subquery, delivery_item_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(work_order_count_subquery, work_order_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == SalesOrder.id)
|
||||
.order_by(SalesOrder.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
|
||||
def get_sales_order_items_query(limit: int = 50, sales_order_id: int | None = None) -> Select:
|
||||
stmt = (
|
||||
select(
|
||||
SalesOrderItem.id.label("sales_order_item_id"),
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
SalesOrder.order_no.label("order_no"),
|
||||
SalesOrderItem.line_no.label("line_no"),
|
||||
SalesOrderItem.product_item_id.label("product_item_id"),
|
||||
Item.item_code.label("product_code"),
|
||||
Item.item_name.label("product_name"),
|
||||
SalesOrderItem.customer_part_no.label("customer_part_no"),
|
||||
SalesOrderItem.order_qty.label("order_qty"),
|
||||
SalesOrderItem.delivered_qty.label("delivered_qty"),
|
||||
SalesOrderItem.unit_price.label("unit_price"),
|
||||
SalesOrderItem.line_amount.label("line_amount"),
|
||||
SalesOrderItem.promised_date.label("promised_date"),
|
||||
SalesOrderItem.status.label("status"),
|
||||
)
|
||||
.join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id)
|
||||
.join(Item, Item.id == SalesOrderItem.product_item_id)
|
||||
.order_by(SalesOrderItem.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if sales_order_id:
|
||||
stmt = stmt.where(SalesOrderItem.sales_order_id == sales_order_id)
|
||||
return stmt
|
||||
|
||||
|
||||
def get_mrp_demands_query(limit: int = 50, sales_order_id: int | None = None) -> Select:
|
||||
product_item = aliased(Item)
|
||||
material_item = aliased(Item)
|
||||
material_ext = aliased(Material)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
MaterialDemand.id.label("demand_id"),
|
||||
MaterialDemand.demand_no.label("demand_no"),
|
||||
SalesOrder.order_no.label("order_no"),
|
||||
MaterialDemand.material_item_id.label("material_item_id"),
|
||||
product_item.item_code.label("product_code"),
|
||||
product_item.item_name.label("product_name"),
|
||||
material_item.item_code.label("material_code"),
|
||||
material_item.item_name.label("material_name"),
|
||||
material_ext.purchase_calc_mode.label("purchase_calc_mode"),
|
||||
MaterialDemand.order_qty.label("order_qty"),
|
||||
MaterialDemand.theoretical_weight_kg.label("theoretical_weight_kg"),
|
||||
MaterialDemand.planned_weight_kg.label("planned_weight_kg"),
|
||||
MaterialDemand.shortage_weight_kg.label("shortage_weight_kg"),
|
||||
MaterialDemand.suggested_purchase_qty.label("suggested_purchase_qty"),
|
||||
MaterialDemand.suggested_purchase_weight_kg.label("suggested_purchase_weight_kg"),
|
||||
MaterialDemand.status.label("status"),
|
||||
MaterialDemand.generated_at.label("generated_at"),
|
||||
)
|
||||
.join(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id)
|
||||
.join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id)
|
||||
.join(product_item, product_item.id == MaterialDemand.product_item_id)
|
||||
.join(material_item, material_item.id == MaterialDemand.material_item_id)
|
||||
.outerjoin(material_ext, material_ext.item_id == MaterialDemand.material_item_id)
|
||||
.where(MaterialDemand.status != "SUPERSEDED")
|
||||
.order_by(MaterialDemand.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if sales_order_id:
|
||||
stmt = stmt.where(SalesOrder.id == sales_order_id)
|
||||
return stmt
|
||||
|
||||
|
||||
def generate_mrp_for_order(db: Session, sales_order_id: int) -> tuple[SalesOrder, list[MaterialDemandRead]]:
|
||||
sales_order = db.get(SalesOrder, sales_order_id)
|
||||
if not sales_order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
|
||||
order_items = db.scalars(
|
||||
select(SalesOrderItem).where(SalesOrderItem.sales_order_id == sales_order_id).order_by(SalesOrderItem.line_no)
|
||||
).all()
|
||||
if not order_items:
|
||||
raise HTTPException(status_code=400, detail="订单没有明细,无法生成MRP")
|
||||
|
||||
item_ids = [item.id for item in order_items]
|
||||
if item_ids:
|
||||
existing_demands = db.scalars(
|
||||
select(MaterialDemand).where(MaterialDemand.sales_order_item_id.in_(item_ids))
|
||||
).all()
|
||||
existing_demand_ids = [demand.id for demand in existing_demands]
|
||||
referenced_demand_ids: set[int] = set()
|
||||
if existing_demand_ids:
|
||||
referenced_demand_ids = set(
|
||||
db.scalars(
|
||||
select(PurchaseOrderItem.source_demand_id).where(
|
||||
PurchaseOrderItem.source_demand_id.in_(existing_demand_ids)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
deletable_demand_ids = [
|
||||
demand_id for demand_id in existing_demand_ids if demand_id not in referenced_demand_ids
|
||||
]
|
||||
if deletable_demand_ids:
|
||||
db.execute(delete(MaterialDemand).where(MaterialDemand.id.in_(deletable_demand_ids)))
|
||||
|
||||
for demand in existing_demands:
|
||||
if demand.id in referenced_demand_ids:
|
||||
demand.status = "SUPERSEDED"
|
||||
demand.remark = "已被重新生成的MRP替代,因已被采购订单引用而保留追溯"
|
||||
db.add(demand)
|
||||
|
||||
db.flush()
|
||||
|
||||
generated_at = datetime.now()
|
||||
created_rows: list[MaterialDemand] = []
|
||||
|
||||
for order_item in order_items:
|
||||
product = db.get(Item, order_item.product_item_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail=f"产品ID {order_item.product_item_id} 不存在")
|
||||
|
||||
bom = db.scalar(
|
||||
select(Bom).where(Bom.product_item_id == product.id, Bom.is_default == 1, Bom.status == "ACTIVE")
|
||||
)
|
||||
if not bom:
|
||||
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 缺少默认BOM")
|
||||
|
||||
bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all()
|
||||
if not bom_items:
|
||||
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 的BOM没有明细")
|
||||
|
||||
order_qty = decimalize(order_item.order_qty)
|
||||
|
||||
for bom_item in bom_items:
|
||||
material_item = db.get(Item, bom_item.material_item_id)
|
||||
if not material_item:
|
||||
raise HTTPException(status_code=400, detail=f"BOM原材料ID {bom_item.material_item_id} 不存在")
|
||||
|
||||
material_ext = db.scalar(select(Material).where(Material.item_id == material_item.id))
|
||||
if not material_ext:
|
||||
raise HTTPException(status_code=400, detail=f"原材料 {material_item.item_name} 缺少采购扩展信息")
|
||||
|
||||
loss_multiplier = Decimal("1") + decimalize(bom_item.loss_rate or 0, "0.0001")
|
||||
material_unit_weight = Decimal(str(material_item.unit_weight_kg or 0))
|
||||
|
||||
available_stock_weight = Decimal(
|
||||
str(
|
||||
db.scalar(
|
||||
select(func.coalesce(func.sum(StockBalance.weight_available_kg), 0)).where(
|
||||
StockBalance.item_id == material_item.id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
)
|
||||
|
||||
gross_weight_per_piece = decimalize(bom_item.gross_weight_per_piece_kg)
|
||||
if gross_weight_per_piece <= 0 and material_unit_weight > 0:
|
||||
gross_weight_per_piece = decimalize(bom_item.qty_per_piece) * material_unit_weight
|
||||
theoretical_weight = order_qty * gross_weight_per_piece
|
||||
planned_weight = theoretical_weight * loss_multiplier
|
||||
|
||||
shortage_weight = max(Decimal("0"), planned_weight - available_stock_weight)
|
||||
|
||||
suggested_purchase_weight = shortage_weight
|
||||
purchase_multiple_weight = Decimal(str(material_ext.purchase_multiple_weight_kg or 0))
|
||||
min_purchase_weight = Decimal(str(material_ext.min_purchase_qty or 0))
|
||||
if suggested_purchase_weight > 0:
|
||||
if purchase_multiple_weight > 0:
|
||||
suggested_purchase_weight = round_up_by_multiple(suggested_purchase_weight, purchase_multiple_weight)
|
||||
elif min_purchase_weight > 0 and suggested_purchase_weight < min_purchase_weight:
|
||||
suggested_purchase_weight = min_purchase_weight.quantize(Decimal("0.000001"))
|
||||
|
||||
demand = MaterialDemand(
|
||||
demand_no=build_mrp_demand_no(db, sales_order, order_item, material_item),
|
||||
sales_order_item_id=order_item.id,
|
||||
product_item_id=product.id,
|
||||
material_item_id=material_item.id,
|
||||
bom_item_id=bom_item.id,
|
||||
order_qty=order_qty,
|
||||
theoretical_weight_kg=theoretical_weight,
|
||||
planned_weight_kg=planned_weight,
|
||||
available_stock_weight_kg=available_stock_weight,
|
||||
shortage_weight_kg=shortage_weight,
|
||||
suggested_purchase_qty=Decimal("0"),
|
||||
suggested_purchase_weight_kg=suggested_purchase_weight,
|
||||
status="NEW",
|
||||
generated_at=generated_at,
|
||||
remark="由订单自动生成",
|
||||
)
|
||||
db.add(demand)
|
||||
created_rows.append(demand)
|
||||
|
||||
db.commit()
|
||||
|
||||
rows = db.execute(get_mrp_demands_query(limit=200, sales_order_id=sales_order_id)).mappings().all()
|
||||
return sales_order, [MaterialDemandRead.model_validate(dict(row)) for row in rows]
|
||||
@@ -0,0 +1,425 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import Item, Warehouse
|
||||
from app.models.operations import InventoryTxn, SpecialWarehouseAdjustment, SpecialWarehouseAdjustmentLine, StockLot
|
||||
from app.schemas.operations import SpecialWarehouseAdjustmentCreate
|
||||
from app.services.operations import (
|
||||
build_inventory_lot_no,
|
||||
clamp_non_negative,
|
||||
create_inventory_txn,
|
||||
get_location_or_default,
|
||||
to_decimal,
|
||||
upsert_stock_balance,
|
||||
)
|
||||
from app.services.sales_planning import next_doc_no
|
||||
from app.services.stocktake import ensure_warehouses_unlocked
|
||||
|
||||
|
||||
SOURCE_DOC_TYPE = "SPECIAL_ADJUSTMENT"
|
||||
LOT_ROLE = "SPECIAL_ADJUSTMENT"
|
||||
|
||||
|
||||
def _strip_text(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _raise_value_from_http(exc: HTTPException) -> None:
|
||||
detail = getattr(exc, "detail", None)
|
||||
raise ValueError(str(detail or exc)) from exc
|
||||
|
||||
|
||||
def _format_decimal(value: Decimal) -> str:
|
||||
normalized = value.quantize(Decimal("0.000001")).normalize()
|
||||
if normalized == normalized.to_integral():
|
||||
return str(normalized.quantize(Decimal("1")))
|
||||
return format(normalized, "f")
|
||||
|
||||
|
||||
def _trim_255(value: str) -> str:
|
||||
return value[:255]
|
||||
|
||||
|
||||
def _trim_to(value: str, limit: int) -> str:
|
||||
if limit <= 0:
|
||||
return ""
|
||||
return value[:limit]
|
||||
|
||||
|
||||
def _warehouse_type_label(warehouse_type: str) -> str:
|
||||
return {
|
||||
"AUX": "辅料",
|
||||
"FINISHED": "成品",
|
||||
"RETURN": "退货",
|
||||
"SEMI": "半成品",
|
||||
"SCRAP": "废品",
|
||||
"RAW": "原材料",
|
||||
}.get(warehouse_type, "库存")
|
||||
|
||||
|
||||
def _build_adjustment_no(db: Session, *, direction: str, adjusted_at: datetime) -> str:
|
||||
direction_label = "入库" if direction == "IN" else "出库"
|
||||
return next_doc_no(
|
||||
db,
|
||||
SpecialWarehouseAdjustment,
|
||||
"adjustment_no",
|
||||
f"特殊调整{adjusted_at.strftime('%Y%m%d')}{direction_label}",
|
||||
)
|
||||
|
||||
|
||||
def _build_special_lot_no(
|
||||
db: Session,
|
||||
*,
|
||||
adjustment_id: int,
|
||||
item_id: int,
|
||||
warehouse_type: str,
|
||||
now: datetime,
|
||||
) -> str:
|
||||
if warehouse_type == "RAW":
|
||||
return build_inventory_lot_no(db, item_id)
|
||||
prefix = f"{_warehouse_type_label(warehouse_type)}特殊{now.strftime('%Y%m%d')}-{adjustment_id}"
|
||||
candidate = next_doc_no(db, StockLot, "lot_no", prefix)
|
||||
sequence = 1
|
||||
while db.scalar(select(StockLot.id).where(StockLot.lot_no == candidate).limit(1)):
|
||||
sequence += 1
|
||||
candidate = f"{prefix}-{sequence:03d}"
|
||||
return candidate
|
||||
|
||||
|
||||
def _normalize_change(
|
||||
*,
|
||||
warehouse_type: str,
|
||||
raw_qty: Decimal,
|
||||
raw_weight: Decimal,
|
||||
) -> tuple[Decimal, Decimal]:
|
||||
qty = to_decimal(raw_qty)
|
||||
weight = to_decimal(raw_weight)
|
||||
if qty < 0 or weight < 0:
|
||||
raise ValueError("特殊调整数量和重量不能小于0")
|
||||
|
||||
if warehouse_type in {"RAW", "SCRAP"}:
|
||||
qty = Decimal("0")
|
||||
if weight <= 0:
|
||||
raise ValueError("特殊调整重量必须大于0")
|
||||
elif warehouse_type == "AUX":
|
||||
weight = Decimal("0")
|
||||
if qty <= 0:
|
||||
raise ValueError("特殊调整数量必须大于0")
|
||||
elif warehouse_type == "SEMI":
|
||||
if qty > 0 and weight > 0:
|
||||
raise ValueError("半成品特殊调整只允许选择数量或重量一种口径")
|
||||
if qty <= 0 and weight <= 0:
|
||||
raise ValueError("特殊调整数量或重量必须大于0")
|
||||
else:
|
||||
if qty <= 0:
|
||||
raise ValueError("特殊调整数量必须大于0")
|
||||
weight = max(weight, Decimal("0"))
|
||||
return to_decimal(qty), to_decimal(weight)
|
||||
|
||||
|
||||
def _assert_out_available(lot: StockLot, *, qty: Decimal, weight: Decimal, warehouse_type: str) -> None:
|
||||
available_qty = clamp_non_negative(to_decimal(lot.remaining_qty) - to_decimal(lot.locked_qty))
|
||||
available_weight = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg))
|
||||
if warehouse_type == "AUX" and qty > available_qty:
|
||||
raise ValueError("特殊出库数量不能超过当前可用数量")
|
||||
if warehouse_type in {"RAW", "SCRAP"} and weight > available_weight:
|
||||
raise ValueError("特殊出库重量不能超过当前可用重量")
|
||||
if warehouse_type not in {"AUX", "RAW", "SCRAP"}:
|
||||
if qty > available_qty:
|
||||
raise ValueError("特殊出库数量不能超过当前可用数量")
|
||||
if weight > available_weight:
|
||||
raise ValueError("特殊出库重量不能超过当前可用重量")
|
||||
|
||||
|
||||
def _build_remark(
|
||||
*,
|
||||
direction: str,
|
||||
reason: str,
|
||||
line_reason: str | None,
|
||||
before_qty: Decimal,
|
||||
after_qty: Decimal,
|
||||
before_weight: Decimal,
|
||||
after_weight: Decimal,
|
||||
) -> str:
|
||||
direction_label = "特殊入库" if direction == "IN" else "特殊出库"
|
||||
metrics = [
|
||||
f"调整前数量:{_format_decimal(before_qty)}",
|
||||
f"调整后数量:{_format_decimal(after_qty)}",
|
||||
f"调整前重量:{_format_decimal(before_weight)}",
|
||||
f"调整后重量:{_format_decimal(after_weight)}",
|
||||
]
|
||||
metric_text = ";".join(metrics)
|
||||
reason_prefix = f"{direction_label}说明:"
|
||||
reason_text = _strip_text(reason)
|
||||
detail = _strip_text(line_reason)
|
||||
|
||||
reserved_without_detail = f"{reason_prefix};{metric_text}"
|
||||
reason_limit = max(0, 255 - len(reserved_without_detail))
|
||||
parts = [f"{reason_prefix}{_trim_to(reason_text, reason_limit)}", metric_text]
|
||||
|
||||
if detail:
|
||||
detail_prefix = "明细说明:"
|
||||
detail_reserved = ";".join(parts) + f";{detail_prefix}"
|
||||
detail_limit = max(0, 255 - len(detail_reserved))
|
||||
trimmed_detail = _trim_to(detail, detail_limit)
|
||||
if trimmed_detail:
|
||||
parts.append(f"{detail_prefix}{trimmed_detail}")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def _create_new_special_lot(
|
||||
db: Session,
|
||||
*,
|
||||
adjustment_id: int,
|
||||
item_id: int,
|
||||
warehouse_id: int,
|
||||
location_id: int | None,
|
||||
warehouse_type: str,
|
||||
qty: Decimal,
|
||||
weight: Decimal,
|
||||
unit_cost: Decimal,
|
||||
reason: str,
|
||||
now: datetime,
|
||||
) -> StockLot:
|
||||
summary = f"特殊入库说明:{reason}"
|
||||
lot = StockLot(
|
||||
lot_no=_build_special_lot_no(
|
||||
db,
|
||||
adjustment_id=adjustment_id,
|
||||
item_id=item_id,
|
||||
warehouse_type=warehouse_type,
|
||||
now=now,
|
||||
),
|
||||
parent_lot_id=None,
|
||||
lot_role=LOT_ROLE,
|
||||
material_sub_batch_no=None,
|
||||
item_id=item_id,
|
||||
warehouse_id=warehouse_id,
|
||||
location_id=location_id,
|
||||
source_doc_type=SOURCE_DOC_TYPE,
|
||||
source_doc_id=adjustment_id,
|
||||
source_line_id=None,
|
||||
source_material_lot_id=None,
|
||||
source_material_sub_batch_no=None,
|
||||
source_material_summary=summary,
|
||||
inbound_qty=qty,
|
||||
inbound_weight_kg=weight,
|
||||
remaining_qty=qty,
|
||||
remaining_weight_kg=weight,
|
||||
locked_qty=to_decimal(0),
|
||||
locked_weight_kg=to_decimal(0),
|
||||
unit_cost=unit_cost,
|
||||
production_date=now.date() if isinstance(now.date(), date) else None,
|
||||
expire_date=None,
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
remark=_trim_255(summary),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(lot)
|
||||
db.flush()
|
||||
return lot
|
||||
|
||||
|
||||
def create_special_inventory_adjustment(
|
||||
db: Session,
|
||||
payload: SpecialWarehouseAdjustmentCreate,
|
||||
user_id: int | None = None,
|
||||
) -> SpecialWarehouseAdjustment:
|
||||
now = datetime.now()
|
||||
direction = str(payload.direction or "").upper()
|
||||
reason = _strip_text(payload.reason)
|
||||
if direction not in {"IN", "OUT"}:
|
||||
raise ValueError("特殊库存调整方向只能是特殊入库或特殊出库")
|
||||
direction_label = "特殊入库" if direction == "IN" else "特殊出库"
|
||||
if len(reason) < 4:
|
||||
raise ValueError(f"{direction_label}必须填写说明")
|
||||
if len(reason) > 1000:
|
||||
raise ValueError(f"{direction_label}说明不能超过1000个字")
|
||||
|
||||
try:
|
||||
warehouse = db.get(Warehouse, payload.warehouse_id)
|
||||
if not warehouse:
|
||||
raise ValueError("仓库不存在")
|
||||
warehouse_type = str(warehouse.warehouse_type or "").upper()
|
||||
ensure_warehouses_unlocked(db, [payload.warehouse_id], direction_label)
|
||||
|
||||
adjustment = SpecialWarehouseAdjustment(
|
||||
adjustment_no=_build_adjustment_no(db, direction=direction, adjusted_at=now),
|
||||
warehouse_id=payload.warehouse_id,
|
||||
warehouse_type=warehouse_type,
|
||||
direction=direction,
|
||||
reason=reason,
|
||||
status="已确认",
|
||||
operator_user_id=user_id,
|
||||
adjusted_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(adjustment)
|
||||
db.flush()
|
||||
|
||||
for index, line_payload in enumerate(payload.lines, start=1):
|
||||
item = db.get(Item, line_payload.item_id)
|
||||
if not item:
|
||||
raise ValueError(f"物料不存在: {line_payload.item_id}")
|
||||
location = get_location_or_default(db, payload.warehouse_id, line_payload.location_id)
|
||||
if location and int(location.warehouse_id) != int(payload.warehouse_id):
|
||||
raise ValueError("选择的库位不属于当前仓库")
|
||||
|
||||
change_qty, change_weight = _normalize_change(
|
||||
warehouse_type=warehouse_type,
|
||||
raw_qty=to_decimal(line_payload.adjust_qty),
|
||||
raw_weight=to_decimal(line_payload.adjust_weight_kg),
|
||||
)
|
||||
unit_cost = to_decimal(line_payload.unit_cost, "0.0001")
|
||||
lot: StockLot | None
|
||||
|
||||
if direction == "OUT":
|
||||
if not line_payload.lot_id:
|
||||
raise ValueError("特殊出库必须选择库存批次")
|
||||
lot = db.get(StockLot, line_payload.lot_id)
|
||||
if not lot or int(lot.item_id) != int(line_payload.item_id) or int(lot.warehouse_id) != int(payload.warehouse_id):
|
||||
raise ValueError("库存批次不存在或不属于当前仓库物料")
|
||||
if location and lot.location_id and int(lot.location_id) != int(location.id):
|
||||
raise ValueError("库存批次库位与选择库位不一致")
|
||||
_assert_out_available(lot, qty=change_qty, weight=change_weight, warehouse_type=warehouse_type)
|
||||
unit_cost = to_decimal(lot.unit_cost, "0.0001") if unit_cost <= 0 else unit_cost
|
||||
before_qty = to_decimal(lot.remaining_qty)
|
||||
before_weight = to_decimal(lot.remaining_weight_kg)
|
||||
after_qty = clamp_non_negative(before_qty - change_qty)
|
||||
after_weight = clamp_non_negative(before_weight - change_weight)
|
||||
qty_delta = -change_qty
|
||||
weight_delta = -change_weight
|
||||
else:
|
||||
if line_payload.lot_id:
|
||||
lot = db.get(StockLot, line_payload.lot_id)
|
||||
if not lot or int(lot.item_id) != int(line_payload.item_id) or int(lot.warehouse_id) != int(payload.warehouse_id):
|
||||
raise ValueError("库存批次不存在或不属于当前仓库物料")
|
||||
if location and lot.location_id and int(lot.location_id) != int(location.id):
|
||||
raise ValueError("库存批次库位与选择库位不一致")
|
||||
unit_cost = to_decimal(lot.unit_cost, "0.0001") if unit_cost <= 0 else unit_cost
|
||||
before_qty = to_decimal(lot.remaining_qty)
|
||||
before_weight = to_decimal(lot.remaining_weight_kg)
|
||||
lot.inbound_qty = to_decimal(lot.inbound_qty) + change_qty
|
||||
lot.inbound_weight_kg = to_decimal(lot.inbound_weight_kg) + change_weight
|
||||
lot.remaining_qty = before_qty + change_qty
|
||||
lot.remaining_weight_kg = before_weight + change_weight
|
||||
lot.status = "AVAILABLE"
|
||||
lot.updated_at = now
|
||||
db.add(lot)
|
||||
else:
|
||||
before_qty = to_decimal(0)
|
||||
before_weight = to_decimal(0)
|
||||
lot = _create_new_special_lot(
|
||||
db,
|
||||
adjustment_id=adjustment.id,
|
||||
item_id=line_payload.item_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
location_id=location.id if location else None,
|
||||
warehouse_type=warehouse_type,
|
||||
qty=change_qty,
|
||||
weight=change_weight,
|
||||
unit_cost=unit_cost,
|
||||
reason=reason,
|
||||
now=now,
|
||||
)
|
||||
after_qty = before_qty + change_qty
|
||||
after_weight = before_weight + change_weight
|
||||
qty_delta = change_qty
|
||||
weight_delta = change_weight
|
||||
|
||||
remark = _build_remark(
|
||||
direction=direction,
|
||||
reason=reason,
|
||||
line_reason=line_payload.line_reason,
|
||||
before_qty=before_qty,
|
||||
after_qty=after_qty,
|
||||
before_weight=before_weight,
|
||||
after_weight=after_weight,
|
||||
)
|
||||
if direction == "OUT":
|
||||
lot.remaining_qty = after_qty
|
||||
lot.remaining_weight_kg = after_weight
|
||||
if after_qty <= 0 and after_weight <= 0:
|
||||
lot.status = "DEPLETED"
|
||||
lot.updated_at = now
|
||||
db.add(lot)
|
||||
|
||||
line = SpecialWarehouseAdjustmentLine(
|
||||
adjustment_id=adjustment.id,
|
||||
line_no=index,
|
||||
item_id=line_payload.item_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
location_id=lot.location_id if lot else (location.id if location else None),
|
||||
lot_id=lot.id,
|
||||
inventory_txn_id=None,
|
||||
before_qty=before_qty,
|
||||
change_qty=qty_delta,
|
||||
after_qty=after_qty,
|
||||
before_weight_kg=before_weight,
|
||||
change_weight_kg=weight_delta,
|
||||
after_weight_kg=after_weight,
|
||||
unit_cost=unit_cost,
|
||||
line_reason=_strip_text(line_payload.line_reason) or reason,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(line)
|
||||
db.flush()
|
||||
if direction == "IN" and lot.source_doc_type == SOURCE_DOC_TYPE and lot.source_line_id is None:
|
||||
lot.source_line_id = line.id
|
||||
db.add(lot)
|
||||
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=line_payload.item_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
qty_delta=qty_delta,
|
||||
weight_delta=weight_delta,
|
||||
available_qty_delta=qty_delta,
|
||||
available_weight_delta=weight_delta,
|
||||
unit_cost=unit_cost,
|
||||
)
|
||||
txn: InventoryTxn = create_inventory_txn(
|
||||
db,
|
||||
txn_type="SPECIAL_IN" if direction == "IN" else "SPECIAL_OUT",
|
||||
item_id=line_payload.item_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
lot_id=lot.id,
|
||||
qty_change=qty_delta,
|
||||
weight_change=weight_delta,
|
||||
unit_cost=unit_cost,
|
||||
source_doc_type=SOURCE_DOC_TYPE,
|
||||
source_doc_id=adjustment.id,
|
||||
source_line_id=line.id,
|
||||
biz_time=now,
|
||||
operator_user_id=user_id,
|
||||
remark=remark,
|
||||
amount_basis="WEIGHT" if abs(weight_delta) > 0 else "QTY",
|
||||
)
|
||||
line.inventory_txn_id = txn.id
|
||||
line.updated_at = now
|
||||
db.add(line)
|
||||
|
||||
db.commit()
|
||||
db.refresh(adjustment)
|
||||
return adjustment
|
||||
except HTTPException as exc:
|
||||
db.rollback()
|
||||
_raise_value_from_http(exc)
|
||||
except ValueError:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,758 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import Item, Warehouse
|
||||
from app.models.operations import StockLot, Stocktake, StocktakeAdjustment, StocktakeLine, StocktakeWarehouse, WarehouseLocation
|
||||
from app.services.operations import create_inventory_txn, to_decimal, upsert_stock_balance
|
||||
|
||||
ACTIVE_STOCKTAKE_STATUSES = {"LOCKED", "EXPORTED", "IMPORTED"}
|
||||
|
||||
STOCKTAKE_EXCEL_HEADERS = [
|
||||
"盘库单号",
|
||||
"盘库校验码",
|
||||
"仓库ID",
|
||||
"快照行ID",
|
||||
"仓库",
|
||||
"库位",
|
||||
"物料编码",
|
||||
"物料名称",
|
||||
"批次号",
|
||||
"来源库存批次号",
|
||||
"系统数量",
|
||||
"系统重量(kg)",
|
||||
"系统单价",
|
||||
"盘点数量",
|
||||
"盘点重量(kg)",
|
||||
"盘点备注",
|
||||
]
|
||||
STOCKTAKE_QUANTITY_HEADERS = {"系统数量", "盘点数量"}
|
||||
|
||||
EMPTY_STOCKTAKE_MARKER_REMARK = "空库盘点校验行,请保留此行用于校验盘库单号"
|
||||
|
||||
|
||||
def build_stocktake_no(db: Session) -> str:
|
||||
date_part = date.today().strftime("%Y%m%d")
|
||||
prefix = f"PK-{date_part}"
|
||||
count = db.scalar(select(func.count(Stocktake.id)).where(Stocktake.stocktake_no.like(f"{prefix}-%"))) or 0
|
||||
return f"{prefix}-{count + 1:03d}"
|
||||
|
||||
|
||||
def stocktake_warehouse_type_label(value: str | None) -> str:
|
||||
return {
|
||||
"RAW": "原材料库",
|
||||
"SEMI": "半成品库",
|
||||
"FINISHED": "成品库",
|
||||
"AUX": "辅料库",
|
||||
"SCRAP": "废料库",
|
||||
"RETURN": "退货库",
|
||||
}.get(str(value or "").upper(), str(value or "未知仓库"))
|
||||
|
||||
|
||||
def stocktake_validation_token(stocktake_no: str, warehouse_id: int, warehouse_type: str | None) -> str:
|
||||
return f"{stocktake_no}|{int(warehouse_id)}|{str(warehouse_type or '').upper()}"
|
||||
|
||||
|
||||
def stocktake_weight_only(warehouse_type: str | None, item_type: str | None) -> bool:
|
||||
_ = item_type
|
||||
return str(warehouse_type or "").upper() in {"RAW", "SCRAP"}
|
||||
|
||||
|
||||
def stocktake_excel_headers_for_type(warehouse_type: str | None) -> list[str]:
|
||||
if stocktake_weight_only(warehouse_type, None):
|
||||
return [header for header in STOCKTAKE_EXCEL_HEADERS if header not in STOCKTAKE_QUANTITY_HEADERS]
|
||||
return list(STOCKTAKE_EXCEL_HEADERS)
|
||||
|
||||
|
||||
def stocktake_excel_row_for_headers(payload: dict[str, object], headers: list[str]) -> list[object]:
|
||||
return [payload.get(header, "") for header in headers]
|
||||
|
||||
|
||||
def get_locked_warehouse_ids(db: Session, warehouse_ids: list[int] | set[int]) -> set[int]:
|
||||
ids = {int(item) for item in warehouse_ids if item}
|
||||
if not ids:
|
||||
return set()
|
||||
rows = db.scalars(
|
||||
select(StocktakeWarehouse.warehouse_id)
|
||||
.join(Stocktake, Stocktake.id == StocktakeWarehouse.stocktake_id)
|
||||
.where(
|
||||
StocktakeWarehouse.warehouse_id.in_(ids),
|
||||
StocktakeWarehouse.status == "LOCKED",
|
||||
Stocktake.status.in_(ACTIVE_STOCKTAKE_STATUSES),
|
||||
)
|
||||
).all()
|
||||
return {int(row) for row in rows}
|
||||
|
||||
|
||||
def ensure_warehouses_unlocked(db: Session, warehouse_ids: list[int] | set[int], action_label: str) -> None:
|
||||
locked_ids = get_locked_warehouse_ids(db, warehouse_ids)
|
||||
if not locked_ids:
|
||||
return
|
||||
rows = db.scalars(select(Warehouse).where(Warehouse.id.in_(locked_ids)).order_by(Warehouse.id.asc())).all()
|
||||
labels = "、".join(row.warehouse_name for row in rows)
|
||||
raise HTTPException(status_code=423, detail=f"{labels}正在盘库,暂不能执行{action_label}")
|
||||
|
||||
|
||||
def decimal_value(value: object, places: str = "0.000001") -> Decimal:
|
||||
return Decimal(str(value or 0)).quantize(Decimal(places))
|
||||
|
||||
|
||||
def current_time() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def start_stocktake(db: Session, *, warehouse_ids: list[int], user_id: int | None, remark: str | None = None) -> Stocktake:
|
||||
ids = [int(item) for item in warehouse_ids if item]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="请选择需要盘库的仓库")
|
||||
if len(set(ids)) != 1:
|
||||
raise HTTPException(status_code=400, detail="一次只能盘一个仓库,请先在仓库栏选中单个仓库")
|
||||
ensure_warehouses_unlocked(db, ids, "开始盘库")
|
||||
warehouses = db.scalars(select(Warehouse).where(Warehouse.id.in_(ids)).order_by(Warehouse.id.asc())).all()
|
||||
if len(warehouses) != len(set(ids)):
|
||||
raise HTTPException(status_code=404, detail="存在无效仓库,无法开始盘库")
|
||||
|
||||
now = current_time()
|
||||
stocktake = Stocktake(
|
||||
stocktake_no=build_stocktake_no(db),
|
||||
status="LOCKED",
|
||||
started_by_user_id=user_id,
|
||||
started_at=now,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(stocktake)
|
||||
db.flush()
|
||||
|
||||
warehouses_by_id = {warehouse.id: warehouse for warehouse in warehouses}
|
||||
stocktake_warehouses_by_id: dict[int, StocktakeWarehouse] = {}
|
||||
for warehouse in warehouses:
|
||||
row = StocktakeWarehouse(
|
||||
stocktake_id=stocktake.id,
|
||||
warehouse_id=warehouse.id,
|
||||
warehouse_type=str(warehouse.warehouse_type or "").upper(),
|
||||
warehouse_name=warehouse.warehouse_name,
|
||||
status="LOCKED",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
stocktake_warehouses_by_id[warehouse.id] = row
|
||||
|
||||
lot_rows = db.execute(
|
||||
select(StockLot, Item)
|
||||
.join(Item, Item.id == StockLot.item_id)
|
||||
.where(
|
||||
StockLot.warehouse_id.in_(ids),
|
||||
StockLot.status == "AVAILABLE",
|
||||
StockLot.remaining_weight_kg > 0,
|
||||
)
|
||||
.order_by(StockLot.warehouse_id.asc(), Item.item_code.asc(), StockLot.lot_no.asc(), StockLot.id.asc())
|
||||
).all()
|
||||
|
||||
line_no = 1
|
||||
for lot, item in lot_rows:
|
||||
warehouse = warehouses_by_id.get(lot.warehouse_id)
|
||||
if not warehouse:
|
||||
continue
|
||||
weight_only = stocktake_weight_only(warehouse.warehouse_type, item.item_type)
|
||||
location_name = None
|
||||
if lot.location_id:
|
||||
location_name = db.scalar(select(WarehouseLocation.location_name).where(WarehouseLocation.id == lot.location_id))
|
||||
db.add(
|
||||
StocktakeLine(
|
||||
stocktake_id=stocktake.id,
|
||||
stocktake_warehouse_id=stocktake_warehouses_by_id[warehouse.id].id,
|
||||
line_no=line_no,
|
||||
warehouse_id=warehouse.id,
|
||||
warehouse_name=warehouse.warehouse_name,
|
||||
warehouse_type=str(warehouse.warehouse_type or "").upper(),
|
||||
location_id=lot.location_id,
|
||||
location_name=location_name,
|
||||
item_id=item.id,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_name,
|
||||
item_type=item.item_type,
|
||||
lot_id=lot.id,
|
||||
lot_no=lot.lot_no,
|
||||
source_material_sub_batch_no=lot.source_material_sub_batch_no or lot.material_sub_batch_no,
|
||||
snapshot_qty=Decimal("0") if weight_only else to_decimal(lot.remaining_qty),
|
||||
snapshot_weight_kg=to_decimal(lot.remaining_weight_kg),
|
||||
snapshot_unit_cost=to_decimal(lot.unit_cost, "0.0001"),
|
||||
counted_qty=None,
|
||||
counted_weight_kg=None,
|
||||
diff_qty=Decimal("0"),
|
||||
diff_weight_kg=Decimal("0"),
|
||||
diff_amount=Decimal("0"),
|
||||
diff_type="MATCH",
|
||||
row_status="SNAPSHOT",
|
||||
remark=None,
|
||||
)
|
||||
)
|
||||
line_no += 1
|
||||
|
||||
db.commit()
|
||||
db.refresh(stocktake)
|
||||
return stocktake
|
||||
|
||||
|
||||
def _stocktake_lines(db: Session, stocktake_id: int) -> list[StocktakeLine]:
|
||||
return db.scalars(
|
||||
select(StocktakeLine)
|
||||
.where(StocktakeLine.stocktake_id == stocktake_id)
|
||||
.order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc())
|
||||
).all()
|
||||
|
||||
|
||||
def _single_stocktake_warehouse(db: Session, stocktake_id: int) -> StocktakeWarehouse:
|
||||
rows = db.scalars(
|
||||
select(StocktakeWarehouse)
|
||||
.where(StocktakeWarehouse.stocktake_id == stocktake_id)
|
||||
.order_by(StocktakeWarehouse.id.asc())
|
||||
).all()
|
||||
if len(rows) != 1:
|
||||
raise HTTPException(status_code=400, detail="当前盘库单不是单仓库盘库,无法继续处理")
|
||||
return rows[0]
|
||||
|
||||
|
||||
def build_stocktake_workbook(db: Session, stocktake_id: int) -> Workbook:
|
||||
stocktake = db.get(Stocktake, stocktake_id)
|
||||
if not stocktake:
|
||||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||||
lines = _stocktake_lines(db, stocktake_id)
|
||||
stocktake_warehouses = db.scalars(
|
||||
select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id).order_by(StocktakeWarehouse.id.asc())
|
||||
).all()
|
||||
workbook = Workbook()
|
||||
default_sheet = workbook.active
|
||||
workbook.remove(default_sheet)
|
||||
grouped: dict[int, list[StocktakeLine]] = {}
|
||||
for line in lines:
|
||||
grouped.setdefault(int(line.warehouse_id), []).append(line)
|
||||
for warehouse in stocktake_warehouses:
|
||||
grouped.setdefault(int(warehouse.warehouse_id), [])
|
||||
warehouses_by_id = {int(warehouse.warehouse_id): warehouse for warehouse in stocktake_warehouses}
|
||||
for warehouse_id, sheet_lines in grouped.items():
|
||||
warehouse = warehouses_by_id.get(int(warehouse_id))
|
||||
if not warehouse:
|
||||
continue
|
||||
sheet_name = stocktake_warehouse_type_label(warehouse.warehouse_type)
|
||||
worksheet = workbook.create_sheet(title=sheet_name[:31])
|
||||
worksheet.freeze_panes = "A2"
|
||||
headers = stocktake_excel_headers_for_type(warehouse.warehouse_type)
|
||||
worksheet.append(headers)
|
||||
if not sheet_lines:
|
||||
token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type)
|
||||
worksheet.append(
|
||||
stocktake_excel_row_for_headers(
|
||||
{
|
||||
"盘库单号": stocktake.stocktake_no,
|
||||
"盘库校验码": token,
|
||||
"仓库ID": warehouse.warehouse_id,
|
||||
"快照行ID": "",
|
||||
"仓库": sheet_name,
|
||||
"库位": "",
|
||||
"物料编码": "",
|
||||
"物料名称": "",
|
||||
"批次号": "",
|
||||
"来源库存批次号": "",
|
||||
"系统数量": 0,
|
||||
"系统重量(kg)": 0,
|
||||
"系统单价": 0,
|
||||
"盘点数量": 0,
|
||||
"盘点重量(kg)": 0,
|
||||
"盘点备注": EMPTY_STOCKTAKE_MARKER_REMARK,
|
||||
},
|
||||
headers,
|
||||
)
|
||||
)
|
||||
for line in sheet_lines:
|
||||
worksheet.append(
|
||||
stocktake_excel_row_for_headers(
|
||||
{
|
||||
"盘库单号": stocktake.stocktake_no,
|
||||
"盘库校验码": stocktake_validation_token(stocktake.stocktake_no, line.warehouse_id, line.warehouse_type),
|
||||
"仓库ID": line.warehouse_id,
|
||||
"快照行ID": line.id,
|
||||
"仓库": line.warehouse_name,
|
||||
"库位": line.location_name or "",
|
||||
"物料编码": line.item_code,
|
||||
"物料名称": line.item_name,
|
||||
"批次号": line.lot_no or "",
|
||||
"来源库存批次号": line.source_material_sub_batch_no or "",
|
||||
"系统数量": float(line.snapshot_qty or 0),
|
||||
"系统重量(kg)": float(line.snapshot_weight_kg or 0),
|
||||
"系统单价": float(line.snapshot_unit_cost or 0),
|
||||
"盘点数量": float(line.snapshot_qty or 0),
|
||||
"盘点重量(kg)": float(line.snapshot_weight_kg or 0),
|
||||
"盘点备注": "",
|
||||
},
|
||||
headers,
|
||||
)
|
||||
)
|
||||
widths_by_header = {
|
||||
"盘库单号": 18,
|
||||
"盘库校验码": 30,
|
||||
"仓库ID": 10,
|
||||
"快照行ID": 12,
|
||||
"仓库": 14,
|
||||
"库位": 14,
|
||||
"物料编码": 18,
|
||||
"物料名称": 28,
|
||||
"批次号": 22,
|
||||
"来源库存批次号": 24,
|
||||
"系统数量": 14,
|
||||
"系统重量(kg)": 16,
|
||||
"系统单价": 12,
|
||||
"盘点数量": 14,
|
||||
"盘点重量(kg)": 16,
|
||||
"盘点备注": 24,
|
||||
}
|
||||
for index, header in enumerate(headers, start=1):
|
||||
width = widths_by_header.get(header, 14)
|
||||
worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width
|
||||
stocktake.exported_at = current_time()
|
||||
stocktake.status = "EXPORTED"
|
||||
db.add(stocktake)
|
||||
db.commit()
|
||||
return workbook
|
||||
|
||||
|
||||
def _clean_text(value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _read_workbook_rows(content: bytes, warehouse_type: str | None = None) -> list[dict[str, object]]:
|
||||
try:
|
||||
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||||
except Exception as exc:
|
||||
raise ValueError("Excel 文件读取失败,请确认上传的是盘库清单 xlsx 文件") from exc
|
||||
rows: list[dict[str, object]] = []
|
||||
for worksheet in workbook.worksheets:
|
||||
values = list(worksheet.iter_rows(values_only=True))
|
||||
if not values:
|
||||
continue
|
||||
headers = [_clean_text(item) for item in values[0]]
|
||||
required_headers = stocktake_excel_headers_for_type(warehouse_type)
|
||||
missing = [header for header in required_headers if header not in headers]
|
||||
if missing:
|
||||
raise ValueError(f"{worksheet.title} 缺少必要列:{'、'.join(missing)}")
|
||||
for row in values[1:]:
|
||||
if not any(_clean_text(item) for item in row):
|
||||
continue
|
||||
payload = {headers[index]: value for index, value in enumerate(row) if index < len(headers)}
|
||||
payload["_sheet_name"] = worksheet.title
|
||||
rows.append(payload)
|
||||
return rows
|
||||
|
||||
|
||||
def _is_empty_stocktake_marker_row(row: dict[str, object]) -> bool:
|
||||
remark = _clean_text(row.get("盘点备注"))
|
||||
if "空库" not in remark:
|
||||
return False
|
||||
if _clean_text(row.get("快照行ID")):
|
||||
return False
|
||||
if any(_clean_text(row.get(label)) for label in ["物料编码", "物料名称", "批次号", "来源库存批次号"]):
|
||||
return False
|
||||
return all(
|
||||
decimal_value(row.get(label)) == 0
|
||||
for label in ["系统数量", "系统重量(kg)", "系统单价", "盘点数量", "盘点重量(kg)"]
|
||||
)
|
||||
|
||||
|
||||
def _validate_stocktake_row_marker(row: dict[str, object], stocktake: Stocktake, warehouse: StocktakeWarehouse) -> None:
|
||||
row_stocktake_no = _clean_text(row.get("盘库单号"))
|
||||
if row_stocktake_no != stocktake.stocktake_no:
|
||||
raise ValueError(f"存在不属于当前盘库单的行:{row_stocktake_no or '空盘库单号'}")
|
||||
|
||||
row_warehouse_id = _clean_text(row.get("仓库ID"))
|
||||
if row_warehouse_id and int(float(row_warehouse_id)) != int(warehouse.warehouse_id):
|
||||
raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致")
|
||||
|
||||
row_warehouse_name = _clean_text(row.get("仓库"))
|
||||
if row_warehouse_name and row_warehouse_name != warehouse.warehouse_name:
|
||||
raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致")
|
||||
|
||||
expected_token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type)
|
||||
row_token = _clean_text(row.get("盘库校验码"))
|
||||
if row_token != expected_token:
|
||||
raise ValueError("盘库校验码不匹配,请导入本次盘库导出的仓库清单")
|
||||
|
||||
|
||||
def _reset_stocktake_preview_for_reimport(db: Session, stocktake_id: int, warehouse_id: int) -> dict[int, StocktakeLine]:
|
||||
lines = db.scalars(
|
||||
select(StocktakeLine)
|
||||
.where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == warehouse_id)
|
||||
.order_by(StocktakeLine.line_no.asc())
|
||||
).all()
|
||||
existing_lines: dict[int, StocktakeLine] = {}
|
||||
for line in lines:
|
||||
if line.lot_id is None and line.diff_type == "NEW_LOT":
|
||||
db.delete(line)
|
||||
continue
|
||||
line.counted_qty = None
|
||||
line.counted_weight_kg = None
|
||||
line.diff_qty = Decimal("0")
|
||||
line.diff_weight_kg = Decimal("0")
|
||||
line.diff_amount = Decimal("0")
|
||||
line.diff_type = "MATCH"
|
||||
line.row_status = "SNAPSHOT"
|
||||
line.error_message = None
|
||||
line.remark = None
|
||||
db.add(line)
|
||||
existing_lines[line.id] = line
|
||||
db.flush()
|
||||
return existing_lines
|
||||
|
||||
|
||||
def _line_diff_type(qty_diff: Decimal, weight_diff: Decimal, *, is_new: bool = False, is_missing: bool = False) -> str:
|
||||
if is_new:
|
||||
return "NEW_LOT"
|
||||
if is_missing:
|
||||
return "MISSING"
|
||||
basis = weight_diff if weight_diff != 0 else qty_diff
|
||||
if basis > 0:
|
||||
return "GAIN"
|
||||
if basis < 0:
|
||||
return "LOSS"
|
||||
return "MATCH"
|
||||
|
||||
|
||||
def _stocktake_summary(lines: list[StocktakeLine]) -> dict[str, int | float]:
|
||||
summary = {
|
||||
"match_count": 0,
|
||||
"gain_count": 0,
|
||||
"loss_count": 0,
|
||||
"new_lot_count": 0,
|
||||
"missing_count": 0,
|
||||
"error_count": 0,
|
||||
"diff_amount": 0.0,
|
||||
}
|
||||
for line in lines:
|
||||
key = {
|
||||
"MATCH": "match_count",
|
||||
"GAIN": "gain_count",
|
||||
"LOSS": "loss_count",
|
||||
"NEW_LOT": "new_lot_count",
|
||||
"MISSING": "missing_count",
|
||||
}.get(line.diff_type)
|
||||
if key:
|
||||
summary[key] += 1
|
||||
if line.row_status == "ERROR":
|
||||
summary["error_count"] += 1
|
||||
summary["diff_amount"] += float(line.diff_amount or 0)
|
||||
return summary
|
||||
|
||||
|
||||
def import_stocktake_workbook(db: Session, stocktake_id: int, content: bytes, user_id: int | None) -> dict[str, object]:
|
||||
stocktake = db.get(Stocktake, stocktake_id)
|
||||
if not stocktake:
|
||||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||||
if stocktake.status not in {"LOCKED", "EXPORTED", "IMPORTED"}:
|
||||
raise HTTPException(status_code=400, detail="当前盘库单状态不允许导入")
|
||||
stocktake_warehouse = _single_stocktake_warehouse(db, stocktake_id)
|
||||
|
||||
rows = _read_workbook_rows(content, stocktake_warehouse.warehouse_type)
|
||||
|
||||
existing_lines = _reset_stocktake_preview_for_reimport(db, stocktake_id, stocktake_warehouse.warehouse_id)
|
||||
seen_line_ids: set[int] = set()
|
||||
next_line_no = max((line.line_no for line in existing_lines.values()), default=0) + 1
|
||||
|
||||
for row in rows:
|
||||
_validate_stocktake_row_marker(row, stocktake, stocktake_warehouse)
|
||||
if _is_empty_stocktake_marker_row(row):
|
||||
continue
|
||||
line_id_text = _clean_text(row.get("快照行ID"))
|
||||
counted_qty = decimal_value(row.get("盘点数量"))
|
||||
counted_weight = decimal_value(row.get("盘点重量(kg)"))
|
||||
remark = _clean_text(row.get("盘点备注")) or None
|
||||
if line_id_text:
|
||||
line_id = int(float(line_id_text))
|
||||
line = existing_lines.get(line_id)
|
||||
if not line:
|
||||
raise ValueError(f"快照行ID {line_id} 不存在于当前盘库单")
|
||||
line.error_message = None
|
||||
if line_id in seen_line_ids:
|
||||
line.error_message = "导入清单中该快照行重复"
|
||||
seen_line_ids.add(line_id)
|
||||
snapshot_qty = decimal_value(line.snapshot_qty)
|
||||
snapshot_weight = decimal_value(line.snapshot_weight_kg)
|
||||
weight_only = stocktake_weight_only(line.warehouse_type, line.item_type)
|
||||
qty_diff = Decimal("0") if weight_only else counted_qty - snapshot_qty
|
||||
weight_diff = counted_weight - snapshot_weight
|
||||
line.counted_qty = Decimal("0") if weight_only else counted_qty
|
||||
line.counted_weight_kg = counted_weight
|
||||
line.diff_qty = qty_diff
|
||||
line.diff_weight_kg = weight_diff
|
||||
line.diff_amount = (weight_diff * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01"))
|
||||
line.diff_type = _line_diff_type(qty_diff, weight_diff)
|
||||
line.row_status = "ERROR" if line.error_message else "READY"
|
||||
line.remark = remark
|
||||
db.add(line)
|
||||
continue
|
||||
|
||||
item_code = _clean_text(row.get("物料编码"))
|
||||
item = db.scalar(select(Item).where(Item.item_code == item_code).limit(1))
|
||||
warehouse = db.get(Warehouse, stocktake_warehouse.warehouse_id)
|
||||
if not item or not warehouse:
|
||||
raise ValueError(f"新增批次行物料或仓库不存在:{stocktake_warehouse.warehouse_name} / {item_code}")
|
||||
unit_cost = decimal_value(row.get("系统单价"), "0.0001")
|
||||
weight_only = stocktake_weight_only(warehouse.warehouse_type, item.item_type)
|
||||
db.add(
|
||||
StocktakeLine(
|
||||
stocktake_id=stocktake.id,
|
||||
stocktake_warehouse_id=stocktake_warehouse.id,
|
||||
line_no=next_line_no,
|
||||
warehouse_id=warehouse.id,
|
||||
warehouse_name=warehouse.warehouse_name,
|
||||
warehouse_type=str(warehouse.warehouse_type or "").upper(),
|
||||
location_id=None,
|
||||
location_name=_clean_text(row.get("库位")) or None,
|
||||
item_id=item.id,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_name,
|
||||
item_type=item.item_type,
|
||||
lot_id=None,
|
||||
lot_no=_clean_text(row.get("批次号")) or None,
|
||||
source_material_sub_batch_no=_clean_text(row.get("来源库存批次号")) or None,
|
||||
snapshot_qty=Decimal("0"),
|
||||
snapshot_weight_kg=Decimal("0"),
|
||||
snapshot_unit_cost=unit_cost,
|
||||
counted_qty=Decimal("0") if weight_only else counted_qty,
|
||||
counted_weight_kg=counted_weight,
|
||||
diff_qty=Decimal("0") if weight_only else counted_qty,
|
||||
diff_weight_kg=counted_weight,
|
||||
diff_amount=(counted_weight * unit_cost).quantize(Decimal("0.01")),
|
||||
diff_type="NEW_LOT",
|
||||
row_status="READY",
|
||||
remark=remark,
|
||||
)
|
||||
)
|
||||
next_line_no += 1
|
||||
|
||||
for line_id, line in existing_lines.items():
|
||||
if line_id in seen_line_ids:
|
||||
continue
|
||||
line.counted_qty = Decimal("0")
|
||||
line.counted_weight_kg = Decimal("0")
|
||||
line.diff_qty = -decimal_value(line.snapshot_qty)
|
||||
line.diff_weight_kg = -decimal_value(line.snapshot_weight_kg)
|
||||
line.diff_amount = (decimal_value(line.diff_weight_kg) * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01"))
|
||||
line.diff_type = "MISSING"
|
||||
line.row_status = "READY"
|
||||
line.error_message = None
|
||||
line.remark = "导入清单缺失,按盘点为0处理"
|
||||
db.add(line)
|
||||
|
||||
stocktake.status = "IMPORTED"
|
||||
stocktake.imported_by_user_id = user_id
|
||||
stocktake.imported_at = current_time()
|
||||
db.add(stocktake)
|
||||
db.commit()
|
||||
|
||||
lines = db.scalars(
|
||||
select(StocktakeLine)
|
||||
.where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == stocktake_warehouse.warehouse_id)
|
||||
.order_by(StocktakeLine.line_no.asc())
|
||||
).all()
|
||||
return {"stocktake": stocktake, "lines": lines, "summary": _stocktake_summary(lines)}
|
||||
|
||||
|
||||
def _stocktake_txn_type(diff_type: str) -> str:
|
||||
return {
|
||||
"GAIN": "STOCKTAKE_GAIN",
|
||||
"LOSS": "STOCKTAKE_LOSS",
|
||||
"MISSING": "STOCKTAKE_LOSS",
|
||||
"NEW_LOT": "STOCKTAKE_NEW_LOT",
|
||||
}.get(diff_type, "STOCKTAKE_GAIN")
|
||||
|
||||
|
||||
def confirm_stocktake(
|
||||
db: Session,
|
||||
stocktake_id: int,
|
||||
*,
|
||||
user_id: int | None,
|
||||
confirm_text: str | None = None,
|
||||
remark: str | None = None,
|
||||
) -> Stocktake:
|
||||
_ = confirm_text
|
||||
stocktake = db.get(Stocktake, stocktake_id)
|
||||
if not stocktake:
|
||||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||||
lines = db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id).order_by(StocktakeLine.line_no.asc())).all()
|
||||
now = current_time()
|
||||
if stocktake.status != "IMPORTED":
|
||||
if stocktake.status in {"LOCKED", "EXPORTED"} and not lines:
|
||||
stocktake.status = "IMPORTED"
|
||||
stocktake.imported_by_user_id = user_id
|
||||
stocktake.imported_at = now
|
||||
db.add(stocktake)
|
||||
db.flush()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="请先导入盘点清单并确认差异后再过账")
|
||||
error_lines = [line for line in lines if line.row_status == "ERROR"]
|
||||
if error_lines:
|
||||
raise HTTPException(status_code=400, detail=f"存在 {len(error_lines)} 行异常,不能确认盘库")
|
||||
|
||||
for line in lines:
|
||||
if line.diff_type == "MATCH":
|
||||
continue
|
||||
weight_only = stocktake_weight_only(line.warehouse_type, line.item_type)
|
||||
unit_cost = decimal_value(line.snapshot_unit_cost, "0.0001")
|
||||
qty_change = decimal_value(line.diff_qty)
|
||||
weight_change = decimal_value(line.diff_weight_kg)
|
||||
if line.diff_type == "NEW_LOT":
|
||||
counted_qty = Decimal("0") if weight_only else max(Decimal("0"), decimal_value(line.counted_qty))
|
||||
counted_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg))
|
||||
lot = StockLot(
|
||||
lot_no=line.lot_no or f"{stocktake.stocktake_no}-NEW-{line.line_no:04d}",
|
||||
lot_role="STOCKTAKE_NEW",
|
||||
item_id=line.item_id,
|
||||
warehouse_id=line.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
source_doc_type="STOCKTAKE",
|
||||
source_doc_id=stocktake.id,
|
||||
source_line_id=line.id,
|
||||
source_material_sub_batch_no=line.source_material_sub_batch_no,
|
||||
source_material_summary="盘库新增批次",
|
||||
inbound_qty=counted_qty,
|
||||
inbound_weight_kg=counted_weight,
|
||||
remaining_qty=counted_qty,
|
||||
remaining_weight_kg=counted_weight,
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=unit_cost,
|
||||
production_date=now.date(),
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
remark=line.remark or "盘库新增",
|
||||
)
|
||||
db.add(lot)
|
||||
db.flush()
|
||||
lot_id = lot.id
|
||||
before_qty = Decimal("0")
|
||||
before_weight = Decimal("0")
|
||||
after_qty = counted_qty
|
||||
after_weight = counted_weight
|
||||
qty_change = after_qty
|
||||
weight_change = after_weight
|
||||
else:
|
||||
lot = db.get(StockLot, line.lot_id)
|
||||
if not lot:
|
||||
raise HTTPException(status_code=400, detail=f"盘库行 {line.line_no} 对应批次不存在")
|
||||
before_qty = Decimal("0") if weight_only else decimal_value(lot.remaining_qty)
|
||||
before_weight = decimal_value(lot.remaining_weight_kg)
|
||||
after_qty = Decimal("0") if weight_only else max(Decimal("0"), decimal_value(line.counted_qty))
|
||||
after_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg))
|
||||
qty_change = after_qty - before_qty
|
||||
weight_change = after_weight - before_weight
|
||||
lot.remaining_qty = after_qty
|
||||
lot.remaining_weight_kg = after_weight
|
||||
lot.status = "AVAILABLE" if after_qty > 0 or after_weight > 0 else "DEPLETED"
|
||||
db.add(lot)
|
||||
lot_id = lot.id
|
||||
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=line.item_id,
|
||||
warehouse_id=line.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
qty_delta=qty_change,
|
||||
weight_delta=weight_change,
|
||||
available_qty_delta=qty_change,
|
||||
available_weight_delta=weight_change,
|
||||
unit_cost=unit_cost,
|
||||
)
|
||||
txn = create_inventory_txn(
|
||||
db,
|
||||
txn_type=_stocktake_txn_type(line.diff_type),
|
||||
item_id=line.item_id,
|
||||
warehouse_id=line.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
lot_id=lot_id,
|
||||
qty_change=qty_change,
|
||||
weight_change=weight_change,
|
||||
unit_cost=unit_cost,
|
||||
source_doc_type="STOCKTAKE",
|
||||
source_doc_id=stocktake.id,
|
||||
source_line_id=line.id,
|
||||
biz_time=now,
|
||||
operator_user_id=user_id,
|
||||
remark=line.remark or remark or "盘库调整",
|
||||
amount_basis="WEIGHT",
|
||||
)
|
||||
db.flush()
|
||||
db.add(
|
||||
StocktakeAdjustment(
|
||||
stocktake_id=stocktake.id,
|
||||
stocktake_line_id=line.id,
|
||||
warehouse_id=line.warehouse_id,
|
||||
item_id=line.item_id,
|
||||
lot_id=line.lot_id,
|
||||
adjustment_lot_id=lot_id,
|
||||
inventory_txn_id=txn.id,
|
||||
adjustment_type=line.diff_type,
|
||||
before_qty=before_qty,
|
||||
after_qty=after_qty,
|
||||
before_weight_kg=before_weight,
|
||||
after_weight_kg=after_weight,
|
||||
qty_change=qty_change,
|
||||
weight_change_kg=weight_change,
|
||||
unit_cost=unit_cost,
|
||||
amount=(abs(weight_change) * unit_cost).quantize(Decimal("0.01")),
|
||||
)
|
||||
)
|
||||
|
||||
for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all():
|
||||
warehouse.status = "UNLOCKED"
|
||||
db.add(warehouse)
|
||||
stocktake.status = "CONFIRMED"
|
||||
stocktake.confirmed_by_user_id = user_id
|
||||
stocktake.confirmed_at = now
|
||||
if remark:
|
||||
stocktake.remark = remark
|
||||
db.add(stocktake)
|
||||
db.commit()
|
||||
db.refresh(stocktake)
|
||||
return stocktake
|
||||
|
||||
|
||||
def cancel_stocktake(db: Session, stocktake_id: int, *, user_id: int | None, remark: str | None = None) -> Stocktake:
|
||||
stocktake = db.get(Stocktake, stocktake_id)
|
||||
if not stocktake:
|
||||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||||
if stocktake.status not in ACTIVE_STOCKTAKE_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="当前盘库单不能取消")
|
||||
now = current_time()
|
||||
for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all():
|
||||
warehouse.status = "UNLOCKED"
|
||||
db.add(warehouse)
|
||||
stocktake.status = "CANCELED"
|
||||
stocktake.canceled_by_user_id = user_id
|
||||
stocktake.canceled_at = now
|
||||
stocktake.remark = remark or stocktake.remark
|
||||
db.add(stocktake)
|
||||
db.commit()
|
||||
db.refresh(stocktake)
|
||||
return stocktake
|
||||
|
||||
|
||||
def stocktake_lines_for_read(db: Session, stocktake_id: int) -> list[StocktakeLine]:
|
||||
return db.scalars(
|
||||
select(StocktakeLine)
|
||||
.where(StocktakeLine.stocktake_id == stocktake_id)
|
||||
.order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc())
|
||||
).all()
|
||||
|
||||
|
||||
def stocktake_warehouses_for_read(db: Session, stocktake_id: int) -> list[StocktakeWarehouse]:
|
||||
return db.scalars(
|
||||
select(StocktakeWarehouse)
|
||||
.where(StocktakeWarehouse.stocktake_id == stocktake_id)
|
||||
.order_by(StocktakeWarehouse.id.asc())
|
||||
).all()
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.org import SystemConfig
|
||||
|
||||
RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE = "RAW_MATERIAL_LOT_PREFIX"
|
||||
RAW_MATERIAL_LOT_PREFIX_DEFAULT = "YL"
|
||||
SMART_OPERATION_REPORT_CONFIG_CODE = "SMART_OPERATION_REPORT_ENABLED"
|
||||
MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE = "MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT"
|
||||
MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME = "小程序报工上次同步成功时间"
|
||||
MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
def normalize_raw_material_lot_prefix(value: str | None) -> str:
|
||||
normalized = re.sub(r"[^A-Z0-9]", "", str(value or "").upper())
|
||||
return normalized or RAW_MATERIAL_LOT_PREFIX_DEFAULT
|
||||
|
||||
|
||||
def normalize_enabled_config_value(value: object) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "开启" if value else "关闭"
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in {"开启", "启用", "true", "1", "yes", "on", "enabled"}:
|
||||
return "开启"
|
||||
if normalized in {"关闭", "停用", "false", "0", "no", "off", "disabled"}:
|
||||
return "关闭"
|
||||
return "开启"
|
||||
|
||||
|
||||
def get_system_config_value(db: Session, config_code: str, default: str = "") -> str:
|
||||
value = db.scalar(
|
||||
select(SystemConfig.config_value).where(
|
||||
SystemConfig.config_code == config_code,
|
||||
SystemConfig.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
return str(value) if value is not None else default
|
||||
|
||||
|
||||
def get_raw_material_lot_prefix(db: Session) -> str:
|
||||
value = get_system_config_value(
|
||||
db,
|
||||
RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
|
||||
RAW_MATERIAL_LOT_PREFIX_DEFAULT,
|
||||
)
|
||||
return normalize_raw_material_lot_prefix(value)
|
||||
|
||||
|
||||
def get_smart_operation_report_enabled(db: Session) -> bool:
|
||||
value = get_system_config_value(db, SMART_OPERATION_REPORT_CONFIG_CODE, "开启")
|
||||
return normalize_enabled_config_value(value) == "开启"
|
||||
|
||||
|
||||
def format_miniapp_operation_report_last_sync(value: datetime) -> str:
|
||||
return value.strftime(MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT)
|
||||
|
||||
|
||||
def parse_miniapp_operation_report_last_sync(value: str | None) -> datetime | None:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return datetime.strptime(normalized, MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT)
|
||||
|
||||
|
||||
def get_miniapp_operation_report_last_sync(db: Session) -> datetime | None:
|
||||
value = get_system_config_value(db, MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, "")
|
||||
return parse_miniapp_operation_report_last_sync(value)
|
||||
|
||||
|
||||
def upsert_miniapp_operation_report_last_sync(
|
||||
db: Session,
|
||||
timestamp: datetime,
|
||||
*,
|
||||
updated_by: int | None = None,
|
||||
) -> SystemConfig:
|
||||
return upsert_system_config(
|
||||
db,
|
||||
config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
|
||||
config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME,
|
||||
config_value=format_miniapp_operation_report_last_sync(timestamp),
|
||||
remark="工序报工小程序同步 ERP 的上次成功截止时间",
|
||||
updated_by=updated_by,
|
||||
now=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def ensure_miniapp_operation_report_last_sync_config(
|
||||
db: Session,
|
||||
*,
|
||||
updated_by: int | None = None,
|
||||
) -> SystemConfig:
|
||||
row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE))
|
||||
if row:
|
||||
return row
|
||||
timestamp = datetime.now()
|
||||
row = SystemConfig(
|
||||
config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
|
||||
config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME,
|
||||
config_value="",
|
||||
remark="工序报工小程序同步 ERP 的上次成功截止时间",
|
||||
status="ACTIVE",
|
||||
created_by=updated_by,
|
||||
updated_by=updated_by,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def lock_miniapp_operation_report_last_sync(db: Session) -> SystemConfig | None:
|
||||
ensure_miniapp_operation_report_last_sync_config(db)
|
||||
return db.scalar(
|
||||
select(SystemConfig)
|
||||
.where(
|
||||
SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
|
||||
SystemConfig.status == "ACTIVE",
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def upsert_smart_operation_report_config(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool,
|
||||
updated_by: int | None = None,
|
||||
remark: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> SystemConfig:
|
||||
return upsert_system_config(
|
||||
db,
|
||||
config_code=SMART_OPERATION_REPORT_CONFIG_CODE,
|
||||
config_name="对接智能报工小程序",
|
||||
config_value=normalize_enabled_config_value(enabled),
|
||||
remark=remark or "开启后显示工序报工并按小程序报工数据计算生产进度;关闭后按ERP入库数据反推生产进度",
|
||||
updated_by=updated_by,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def lock_raw_material_lot_prefix_config(db: Session) -> None:
|
||||
"""Serialize lot-number generation on databases that support row locks."""
|
||||
db.scalar(
|
||||
select(SystemConfig.id)
|
||||
.where(
|
||||
SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
|
||||
SystemConfig.status == "ACTIVE",
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def upsert_system_config(
|
||||
db: Session,
|
||||
*,
|
||||
config_code: str,
|
||||
config_name: str,
|
||||
config_value: str,
|
||||
remark: str | None = None,
|
||||
updated_by: int | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> SystemConfig:
|
||||
timestamp = now or datetime.now()
|
||||
saved_value = (
|
||||
normalize_raw_material_lot_prefix(config_value)
|
||||
if config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE
|
||||
else str(config_value or "").strip()
|
||||
)
|
||||
row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == config_code))
|
||||
if not row:
|
||||
row = SystemConfig(
|
||||
config_code=config_code,
|
||||
created_by=updated_by,
|
||||
created_at=timestamp,
|
||||
)
|
||||
|
||||
row.config_name = config_name
|
||||
row.config_value = saved_value
|
||||
row.remark = remark
|
||||
row.status = "ACTIVE"
|
||||
row.updated_by = updated_by
|
||||
row.updated_at = timestamp
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
# Use a Core update so caller-supplied business timestamps are not overwritten by
|
||||
# the global ORM before_update audit hook.
|
||||
db.execute(
|
||||
update(SystemConfig)
|
||||
.where(SystemConfig.id == row.id)
|
||||
.values(
|
||||
config_name=config_name,
|
||||
config_value=saved_value,
|
||||
remark=remark,
|
||||
status="ACTIVE",
|
||||
updated_by=updated_by,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -0,0 +1,905 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, delete, exists, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.org import (
|
||||
Department,
|
||||
Employee,
|
||||
OrgEmployeeBinding,
|
||||
OrgManagerBinding,
|
||||
Permission,
|
||||
Role,
|
||||
RolePermission,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
from app.schemas.system_permissions import (
|
||||
EmployeeOption,
|
||||
MenuPermissionNode,
|
||||
OrgEmployeeNode,
|
||||
OrgEmployeeBind,
|
||||
OrgNodeCreate,
|
||||
OrgNodeRead,
|
||||
OrgNodeUpdate,
|
||||
PermissionRead,
|
||||
RoleCreate,
|
||||
RoleRead,
|
||||
RoleUpdate,
|
||||
SystemUserCreate,
|
||||
SystemUserRead,
|
||||
)
|
||||
from app.services.auth import hash_password
|
||||
|
||||
|
||||
MENU_PERMISSION_TREE = [
|
||||
("MENU_DASHBOARD", "经营驾驶舱", []),
|
||||
(
|
||||
"MENU_MASTER_DATA",
|
||||
"基础资料",
|
||||
[
|
||||
("MENU_SUPPLIER_MANAGEMENT", "供应商名录", []),
|
||||
("MENU_MATERIAL_MANAGEMENT", "原材料名录", []),
|
||||
("MENU_SALES_PLANNING", "客户名录/销售订单", []),
|
||||
("MENU_PRODUCT_MANAGEMENT", "产品管理", []),
|
||||
("MENU_BOM_MANAGEMENT", "BOM管理", []),
|
||||
("MENU_ROUTE_MANAGEMENT", "工艺路线管理", []),
|
||||
("MENU_EMPLOYEE_MANAGEMENT", "人员管理", []),
|
||||
],
|
||||
),
|
||||
(
|
||||
"MENU_PROCUREMENT",
|
||||
"采购与库存",
|
||||
[
|
||||
("MENU_PURCHASE_ORDER", "采购订单", []),
|
||||
("MENU_PURCHASE_RECEIPT", "到货入库", []),
|
||||
("MENU_QUALITY_INSPECTION", "质量检验", []),
|
||||
("MENU_INVENTORY_LEDGER", "嘉恒仓库", []),
|
||||
],
|
||||
),
|
||||
(
|
||||
"MENU_PRODUCTION",
|
||||
"生产执行",
|
||||
[
|
||||
("MENU_WORK_ORDER", "工单台账", []),
|
||||
("MENU_OPERATION_REPORT", "工序报工", []),
|
||||
("MENU_EQUIPMENT_MANAGEMENT", "设备管理", []),
|
||||
],
|
||||
),
|
||||
("MENU_DELIVERY_MANAGEMENT", "发货概览", []),
|
||||
("MENU_FINANCE_REPORT", "财务分析", []),
|
||||
(
|
||||
"MENU_SYSTEM_MANAGEMENT",
|
||||
"系统管理",
|
||||
[
|
||||
("MENU_SYSTEM_PERMISSION", "系统权限管理", []),
|
||||
("MENU_SYSTEM_EXTENSION", "系统拓展", []),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
ORG_ROOT_CODE = "ORG_ROOT"
|
||||
ORG_ROOT_NAME = "总公司"
|
||||
VALID_CHILD_NODE_TYPES = {
|
||||
"COMPANY": {"BRANCH": "总公司下只能新增分公司"},
|
||||
"BRANCH": {"DEPARTMENT": "分公司下只能新增部门"},
|
||||
"DEPARTMENT": {"GROUP": "部门下只能新增小组"},
|
||||
"GROUP": {},
|
||||
}
|
||||
ORG_NODE_TYPE_LABELS = {
|
||||
"COMPANY": "总公司",
|
||||
"BRANCH": "分公司",
|
||||
"DEPARTMENT": "部门",
|
||||
"GROUP": "小组",
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _normalize_phone(value: str | None) -> str:
|
||||
return re.sub(r"\s+", "", str(value or "").strip())
|
||||
|
||||
|
||||
def _employee_name_sort_key(employee: Employee) -> tuple[str, int]:
|
||||
surname_order = {
|
||||
"王": "wang",
|
||||
"李": "li",
|
||||
"张": "zhang",
|
||||
"刘": "liu",
|
||||
"陈": "chen",
|
||||
"杨": "yang",
|
||||
"赵": "zhao",
|
||||
"黄": "huang",
|
||||
"周": "zhou",
|
||||
"吴": "wu",
|
||||
"徐": "xu",
|
||||
"孙": "sun",
|
||||
"胡": "hu",
|
||||
"朱": "zhu",
|
||||
"高": "gao",
|
||||
"林": "lin",
|
||||
"何": "he",
|
||||
"郭": "guo",
|
||||
"马": "ma",
|
||||
"罗": "luo",
|
||||
}
|
||||
name = employee.employee_name or ""
|
||||
return (f"{surname_order.get(name[:1], name[:1])}{name[1:]}", employee.id)
|
||||
|
||||
|
||||
def _menu_node(raw: tuple[str, str, list]) -> MenuPermissionNode:
|
||||
code, name, children = raw
|
||||
return MenuPermissionNode(
|
||||
permission_code=code,
|
||||
permission_name=name,
|
||||
children=[_menu_node(child) for child in children],
|
||||
)
|
||||
|
||||
|
||||
def list_menu_permission_tree() -> list[MenuPermissionNode]:
|
||||
return [_menu_node(item) for item in MENU_PERMISSION_TREE]
|
||||
|
||||
|
||||
def list_permissions(db: Session) -> list[PermissionRead]:
|
||||
rows = db.scalars(select(Permission).order_by(Permission.module_code, Permission.id)).all()
|
||||
return [
|
||||
PermissionRead(
|
||||
permission_id=row.id,
|
||||
permission_code=row.permission_code,
|
||||
permission_name=row.permission_name,
|
||||
module_code=row.module_code,
|
||||
action_code=row.action_code,
|
||||
status=row.status,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _employee_role_map(db: Session, employee_ids: list[int]) -> tuple[dict[int, str], dict[int, list[str]]]:
|
||||
if not employee_ids:
|
||||
return {}, {}
|
||||
rows = db.execute(
|
||||
select(User.employee_id, User.username, Role.role_name)
|
||||
.select_from(User)
|
||||
.outerjoin(UserRole, UserRole.user_id == User.id)
|
||||
.outerjoin(Role, Role.id == UserRole.role_id)
|
||||
.where(User.employee_id.in_(employee_ids))
|
||||
.order_by(User.employee_id, Role.id)
|
||||
).all()
|
||||
username_map: dict[int, str] = {}
|
||||
role_map: dict[int, list[str]] = {}
|
||||
for employee_id, username, role_name in rows:
|
||||
if employee_id is None:
|
||||
continue
|
||||
username_map.setdefault(employee_id, username)
|
||||
if role_name:
|
||||
role_map.setdefault(employee_id, [])
|
||||
if role_name not in role_map[employee_id]:
|
||||
role_map[employee_id].append(role_name)
|
||||
return username_map, role_map
|
||||
|
||||
|
||||
def _ensure_org_root(db: Session) -> Department:
|
||||
root = db.scalar(select(Department).where(Department.dept_code == ORG_ROOT_CODE))
|
||||
if root:
|
||||
return root
|
||||
|
||||
now = _now()
|
||||
root = Department(
|
||||
dept_code=ORG_ROOT_CODE,
|
||||
dept_name=ORG_ROOT_NAME,
|
||||
parent_id=None,
|
||||
org_node_type="COMPANY",
|
||||
dept_type="ADMIN",
|
||||
manager_name=None,
|
||||
manager_employee_id=None,
|
||||
status="ACTIVE",
|
||||
sort_no=0,
|
||||
remark="系统权限管理组织脑图根节点",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(root)
|
||||
db.commit()
|
||||
db.refresh(root)
|
||||
return root
|
||||
|
||||
|
||||
def _managed_department_ids(root: Department, departments: list[Department]) -> set[int]:
|
||||
children_by_parent: dict[int | None, list[Department]] = {}
|
||||
for dept in departments:
|
||||
children_by_parent.setdefault(dept.parent_id, []).append(dept)
|
||||
|
||||
result: set[int] = set()
|
||||
|
||||
def walk(node_id: int) -> None:
|
||||
result.add(node_id)
|
||||
for child in children_by_parent.get(node_id, []):
|
||||
walk(child.id)
|
||||
|
||||
walk(root.id)
|
||||
return result
|
||||
|
||||
|
||||
def _bound_employees_by_dept(db: Session, dept_ids: set[int]) -> dict[int, list[OrgEmployeeNode]]:
|
||||
if not dept_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(OrgEmployeeBinding, Employee)
|
||||
.join(Employee, Employee.id == OrgEmployeeBinding.employee_id)
|
||||
.where(
|
||||
OrgEmployeeBinding.dept_id.in_(dept_ids),
|
||||
OrgEmployeeBinding.status == "ACTIVE",
|
||||
Employee.status == "ACTIVE",
|
||||
)
|
||||
.order_by(OrgEmployeeBinding.dept_id, OrgEmployeeBinding.sort_no, OrgEmployeeBinding.id)
|
||||
).all()
|
||||
employees = [employee for _, employee in rows]
|
||||
username_map, role_map = _employee_role_map(db, [employee.id for employee in employees])
|
||||
employees_by_dept: dict[int, list[OrgEmployeeNode]] = {}
|
||||
for binding, employee in rows:
|
||||
employees_by_dept.setdefault(binding.dept_id, []).append(
|
||||
OrgEmployeeNode(
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.employee_name,
|
||||
mobile=employee.mobile,
|
||||
job_title=employee.job_title,
|
||||
username=username_map.get(employee.id),
|
||||
role_names=role_map.get(employee.id, []),
|
||||
status=employee.status,
|
||||
)
|
||||
)
|
||||
return employees_by_dept
|
||||
|
||||
|
||||
def _active_employees_for_ids(db: Session, employee_ids: list[int]) -> list[Employee]:
|
||||
unique_ids = list(dict.fromkeys(employee_ids))
|
||||
if not unique_ids:
|
||||
return []
|
||||
rows = db.scalars(select(Employee).where(Employee.id.in_(unique_ids), Employee.status == "ACTIVE")).all()
|
||||
employee_map = {employee.id: employee for employee in rows}
|
||||
missing = [employee_id for employee_id in unique_ids if employee_id not in employee_map]
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"主管不存在或已停用:{missing}")
|
||||
return [employee_map[employee_id] for employee_id in unique_ids]
|
||||
|
||||
|
||||
def _manager_payload_ids(payload: OrgNodeCreate | OrgNodeUpdate) -> list[int]:
|
||||
if payload.manager_employee_ids:
|
||||
return list(dict.fromkeys(payload.manager_employee_ids))
|
||||
return [payload.manager_employee_id] if payload.manager_employee_id else []
|
||||
|
||||
|
||||
def _replace_org_managers(db: Session, dept: Department, manager_employee_ids: list[int]) -> None:
|
||||
managers = _active_employees_for_ids(db, manager_employee_ids)
|
||||
db.execute(delete(OrgManagerBinding).where(OrgManagerBinding.dept_id == dept.id))
|
||||
now = _now()
|
||||
for index, manager in enumerate(managers, start=1):
|
||||
db.add(
|
||||
OrgManagerBinding(
|
||||
dept_id=dept.id,
|
||||
employee_id=manager.id,
|
||||
sort_no=index,
|
||||
status="ACTIVE",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
dept.manager_employee_id = managers[0].id if managers else None
|
||||
dept.manager_name = "、".join(manager.employee_name for manager in managers) if managers else None
|
||||
|
||||
|
||||
def _manager_bindings_by_dept(
|
||||
db: Session,
|
||||
dept_ids: set[int],
|
||||
departments: list[Department],
|
||||
) -> dict[int, tuple[list[int], list[str]]]:
|
||||
if not dept_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(OrgManagerBinding, Employee)
|
||||
.join(Employee, Employee.id == OrgManagerBinding.employee_id)
|
||||
.where(
|
||||
OrgManagerBinding.dept_id.in_(dept_ids),
|
||||
OrgManagerBinding.status == "ACTIVE",
|
||||
Employee.status == "ACTIVE",
|
||||
)
|
||||
.order_by(OrgManagerBinding.dept_id, OrgManagerBinding.sort_no, OrgManagerBinding.id)
|
||||
).all()
|
||||
result: dict[int, tuple[list[int], list[str]]] = {}
|
||||
for binding, employee in rows:
|
||||
manager_ids, manager_names = result.setdefault(binding.dept_id, ([], []))
|
||||
manager_ids.append(employee.id)
|
||||
manager_names.append(employee.employee_name)
|
||||
|
||||
legacy_ids = [dept.manager_employee_id for dept in departments if dept.id in dept_ids and dept.manager_employee_id]
|
||||
legacy_employees = (
|
||||
{employee.id: employee for employee in db.scalars(select(Employee).where(Employee.id.in_(legacy_ids))).all()}
|
||||
if legacy_ids
|
||||
else {}
|
||||
)
|
||||
for dept in departments:
|
||||
if dept.id not in dept_ids or dept.id in result:
|
||||
continue
|
||||
if dept.manager_employee_id:
|
||||
employee = legacy_employees.get(dept.manager_employee_id)
|
||||
result[dept.id] = (
|
||||
[dept.manager_employee_id],
|
||||
[employee.employee_name if employee else dept.manager_name or "未命名主管"],
|
||||
)
|
||||
elif dept.manager_name:
|
||||
result[dept.id] = ([], [dept.manager_name])
|
||||
return result
|
||||
|
||||
|
||||
def build_org_tree(db: Session) -> list[OrgNodeRead]:
|
||||
root = _ensure_org_root(db)
|
||||
departments = db.scalars(
|
||||
select(Department).order_by(Department.parent_id.is_not(None), Department.sort_no, Department.id)
|
||||
).all()
|
||||
managed_ids = _managed_department_ids(root, departments)
|
||||
departments = [dept for dept in departments if dept.id in managed_ids]
|
||||
employees_by_dept = _bound_employees_by_dept(db, managed_ids)
|
||||
managers_by_dept = _manager_bindings_by_dept(db, managed_ids, departments)
|
||||
|
||||
nodes: dict[int, OrgNodeRead] = {
|
||||
dept.id: OrgNodeRead(
|
||||
node_id=dept.id,
|
||||
node_type=getattr(dept, "org_node_type", None) or "DEPARTMENT",
|
||||
node_label=dept.dept_name,
|
||||
parent_id=dept.parent_id,
|
||||
dept_code=dept.dept_code,
|
||||
dept_type=dept.dept_type,
|
||||
manager_employee_id=(managers_by_dept.get(dept.id, ([], []))[0] or [None])[0],
|
||||
manager_name="、".join(managers_by_dept.get(dept.id, ([], []))[1]) or None,
|
||||
manager_employee_ids=managers_by_dept.get(dept.id, ([], []))[0],
|
||||
manager_names=managers_by_dept.get(dept.id, ([], []))[1],
|
||||
status=dept.status,
|
||||
sort_no=dept.sort_no,
|
||||
remark=dept.remark,
|
||||
employees=employees_by_dept.get(dept.id, []),
|
||||
children=[],
|
||||
)
|
||||
for dept in departments
|
||||
}
|
||||
roots: list[OrgNodeRead] = []
|
||||
for dept in departments:
|
||||
node = nodes[dept.id]
|
||||
if dept.parent_id and dept.parent_id in nodes:
|
||||
nodes[dept.parent_id].children.append(node)
|
||||
elif dept.id == root.id:
|
||||
roots.append(node)
|
||||
return roots
|
||||
|
||||
|
||||
def _flatten_org_nodes(nodes: list[OrgNodeRead]) -> list[OrgNodeRead]:
|
||||
result: list[OrgNodeRead] = []
|
||||
for node in nodes:
|
||||
result.append(node)
|
||||
result.extend(_flatten_org_nodes(node.children))
|
||||
return result
|
||||
|
||||
|
||||
def _next_department_code(db: Session, node_type: str) -> str:
|
||||
prefix = {"COMPANY": "COMP", "BRANCH": "BR", "DEPARTMENT": "DEPT", "GROUP": "GRP"}.get(node_type, "DEPT")
|
||||
count = db.scalar(select(func.count(Department.id)).where(Department.dept_code.like(f"{prefix}-%"))) or 0
|
||||
return f"{prefix}-{int(count) + 1:03d}"
|
||||
|
||||
|
||||
def _manager_name(db: Session, employee_id: int | None) -> str | None:
|
||||
if not employee_id:
|
||||
return None
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail="负责人不存在或已停用")
|
||||
return employee.employee_name
|
||||
|
||||
|
||||
def create_org_node(db: Session, payload: OrgNodeCreate) -> OrgNodeRead:
|
||||
if not payload.parent_id:
|
||||
if payload.node_type != "COMPANY":
|
||||
raise HTTPException(status_code=400, detail="请选择上级组织节点")
|
||||
parent = db.get(Department, payload.parent_id) if payload.parent_id else None
|
||||
if payload.parent_id and not parent:
|
||||
raise HTTPException(status_code=404, detail="上级组织节点不存在")
|
||||
if parent:
|
||||
parent_type = getattr(parent, "org_node_type", None) or "DEPARTMENT"
|
||||
allowed = VALID_CHILD_NODE_TYPES.get(parent_type, {})
|
||||
if payload.node_type not in allowed:
|
||||
message = next(iter(allowed.values()), f"{ORG_NODE_TYPE_LABELS.get(parent_type, '当前节点')}下不能新增组织节点")
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
dept_code = payload.dept_code or _next_department_code(db, payload.node_type)
|
||||
if db.scalar(select(Department).where(Department.dept_code == dept_code)):
|
||||
raise HTTPException(status_code=400, detail="组织编码已存在")
|
||||
now = _now()
|
||||
row = Department(
|
||||
dept_code=dept_code,
|
||||
dept_name=payload.node_label,
|
||||
parent_id=payload.parent_id,
|
||||
org_node_type=payload.node_type,
|
||||
dept_type=payload.dept_type,
|
||||
manager_name=None,
|
||||
manager_employee_id=None,
|
||||
status=payload.status,
|
||||
sort_no=payload.sort_no,
|
||||
remark=payload.remark,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
_replace_org_managers(db, row, _manager_payload_ids(payload))
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id)
|
||||
|
||||
|
||||
def update_org_node(db: Session, node_id: int, payload: OrgNodeUpdate) -> OrgNodeRead:
|
||||
row = db.get(Department, node_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="组织节点不存在")
|
||||
row.dept_name = payload.node_label
|
||||
row.dept_type = payload.dept_type
|
||||
_replace_org_managers(db, row, _manager_payload_ids(payload))
|
||||
row.sort_no = payload.sort_no
|
||||
row.remark = payload.remark
|
||||
row.status = payload.status
|
||||
row.updated_at = _now()
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id)
|
||||
|
||||
|
||||
def delete_org_node(db: Session, node_id: int) -> None:
|
||||
row = db.get(Department, node_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="组织节点不存在")
|
||||
child_count = db.scalar(select(func.count(Department.id)).where(Department.parent_id == node_id)) or 0
|
||||
if int(child_count) > 0:
|
||||
raise HTTPException(status_code=400, detail="该节点存在下级节点,请先从底层开始删除")
|
||||
employee_count = (
|
||||
db.scalar(
|
||||
select(func.count(OrgEmployeeBinding.id)).where(
|
||||
OrgEmployeeBinding.dept_id == node_id,
|
||||
OrgEmployeeBinding.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if int(employee_count) > 0:
|
||||
raise HTTPException(status_code=400, detail="该节点存在人员节点,请先移除人员")
|
||||
if row.dept_code == ORG_ROOT_CODE:
|
||||
raise HTTPException(status_code=400, detail="总公司根节点不能删除")
|
||||
db.execute(delete(OrgManagerBinding).where(OrgManagerBinding.dept_id == node_id))
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
|
||||
|
||||
def bind_org_employee(db: Session, node_id: int, payload: OrgEmployeeBind) -> OrgNodeRead:
|
||||
node = db.get(Department, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="组织节点不存在")
|
||||
node_type = getattr(node, "org_node_type", None) or "DEPARTMENT"
|
||||
if node_type not in {"DEPARTMENT", "GROUP"}:
|
||||
raise HTTPException(status_code=400, detail="只有部门或小组节点可以新增人员")
|
||||
employee = db.get(Employee, payload.employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail="人员不存在或已停用")
|
||||
|
||||
existing = db.scalar(
|
||||
select(OrgEmployeeBinding).where(
|
||||
OrgEmployeeBinding.dept_id == node_id,
|
||||
OrgEmployeeBinding.employee_id == payload.employee_id,
|
||||
)
|
||||
)
|
||||
now = _now()
|
||||
if existing:
|
||||
existing.status = "ACTIVE"
|
||||
existing.sort_no = payload.sort_no
|
||||
existing.updated_at = now
|
||||
db.add(existing)
|
||||
else:
|
||||
db.add(
|
||||
OrgEmployeeBinding(
|
||||
dept_id=node_id,
|
||||
employee_id=payload.employee_id,
|
||||
sort_no=payload.sort_no,
|
||||
status="ACTIVE",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == node_id)
|
||||
|
||||
|
||||
def unbind_org_employee(db: Session, node_id: int, employee_id: int) -> OrgNodeRead:
|
||||
binding = db.scalar(
|
||||
select(OrgEmployeeBinding).where(
|
||||
OrgEmployeeBinding.dept_id == node_id,
|
||||
OrgEmployeeBinding.employee_id == employee_id,
|
||||
OrgEmployeeBinding.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
if not binding:
|
||||
raise HTTPException(status_code=404, detail="人员节点不存在")
|
||||
db.delete(binding)
|
||||
db.commit()
|
||||
return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == node_id)
|
||||
|
||||
|
||||
def list_roles(db: Session) -> list[RoleRead]:
|
||||
roles = db.scalars(select(Role).order_by(Role.id)).all()
|
||||
if not roles:
|
||||
return []
|
||||
role_ids = [role.id for role in roles]
|
||||
permission_rows = db.execute(
|
||||
select(RolePermission.role_id, Permission.permission_code)
|
||||
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||
.where(RolePermission.role_id.in_(role_ids))
|
||||
.order_by(RolePermission.role_id, Permission.permission_code)
|
||||
).all()
|
||||
user_counts = dict(
|
||||
db.execute(
|
||||
select(UserRole.role_id, func.count(UserRole.user_id))
|
||||
.where(UserRole.role_id.in_(role_ids))
|
||||
.group_by(UserRole.role_id)
|
||||
).all()
|
||||
)
|
||||
permission_map: dict[int, list[str]] = {}
|
||||
for role_id, code in permission_rows:
|
||||
permission_map.setdefault(role_id, []).append(code)
|
||||
return [
|
||||
RoleRead(
|
||||
role_id=role.id,
|
||||
role_code=role.role_code,
|
||||
role_name=role.role_name,
|
||||
role_scope=role.role_scope,
|
||||
status=role.status,
|
||||
remark=role.remark,
|
||||
permission_codes=permission_map.get(role.id, []),
|
||||
user_count=int(user_counts.get(role.id, 0)),
|
||||
created_at=role.created_at,
|
||||
updated_at=role.updated_at,
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _replace_role_permissions(db: Session, role_id: int, permission_codes: list[str]) -> None:
|
||||
permissions = _permissions_for_codes(db, permission_codes)
|
||||
db.execute(delete(RolePermission).where(RolePermission.role_id == role_id))
|
||||
now = _now()
|
||||
for permission in permissions:
|
||||
db.add(RolePermission(role_id=role_id, permission_id=permission.id, created_at=now))
|
||||
|
||||
|
||||
def _permissions_for_codes(db: Session, permission_codes: list[str]) -> list[Permission]:
|
||||
unique_codes = list(dict.fromkeys(permission_codes))
|
||||
permissions = (
|
||||
db.scalars(select(Permission).where(Permission.permission_code.in_(unique_codes))).all()
|
||||
if unique_codes
|
||||
else []
|
||||
)
|
||||
missing = sorted(set(unique_codes) - {row.permission_code for row in permissions})
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"权限不存在:{'、'.join(missing)}")
|
||||
return permissions
|
||||
|
||||
|
||||
def create_role(db: Session, payload: RoleCreate) -> RoleRead:
|
||||
if db.scalar(select(Role).where(Role.role_code == payload.role_code)):
|
||||
raise HTTPException(status_code=400, detail="角色编码已存在")
|
||||
_permissions_for_codes(db, payload.permission_codes)
|
||||
now = _now()
|
||||
row = Role(
|
||||
role_code=payload.role_code,
|
||||
role_name=payload.role_name,
|
||||
role_scope=payload.role_scope,
|
||||
status=payload.status,
|
||||
remark=payload.remark,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
_replace_role_permissions(db, row.id, payload.permission_codes)
|
||||
db.commit()
|
||||
return next(role for role in list_roles(db) if role.role_id == row.id)
|
||||
|
||||
|
||||
def update_role(db: Session, role_id: int, payload: RoleUpdate) -> RoleRead:
|
||||
row = db.get(Role, role_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
_permissions_for_codes(db, payload.permission_codes)
|
||||
row.role_name = payload.role_name
|
||||
row.role_scope = payload.role_scope
|
||||
row.status = payload.status
|
||||
row.remark = payload.remark
|
||||
row.updated_at = _now()
|
||||
db.add(row)
|
||||
_replace_role_permissions(db, row.id, payload.permission_codes)
|
||||
db.commit()
|
||||
return next(role for role in list_roles(db) if role.role_id == row.id)
|
||||
|
||||
|
||||
def _system_user_read(db: Session, user: User) -> SystemUserRead:
|
||||
employee = db.get(Employee, user.employee_id)
|
||||
department = db.get(Department, user.dept_id)
|
||||
role_rows = db.execute(
|
||||
select(Role.id, Role.role_name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user.id)
|
||||
.order_by(Role.id)
|
||||
).all()
|
||||
return SystemUserRead(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
employee_id=user.employee_id,
|
||||
employee_name=employee.employee_name if employee else user.nickname or user.username,
|
||||
dept_id=user.dept_id,
|
||||
dept_name=department.dept_name if department else None,
|
||||
mobile=employee.mobile if employee else None,
|
||||
nickname=user.nickname,
|
||||
is_super_admin=bool(user.is_super_admin),
|
||||
status=user.status,
|
||||
role_ids=[row[0] for row in role_rows],
|
||||
role_names=[row[1] for row in role_rows],
|
||||
last_login_at=user.last_login_at,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def list_system_users(db: Session) -> list[SystemUserRead]:
|
||||
rows = db.scalars(select(User).order_by(User.status, User.id)).all()
|
||||
return [_system_user_read(db, row) for row in rows]
|
||||
|
||||
|
||||
def _permission_by_code(db: Session, permission_code: str) -> Permission:
|
||||
permission = db.scalar(
|
||||
select(Permission).where(
|
||||
Permission.permission_code == permission_code,
|
||||
Permission.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
if not permission:
|
||||
raise HTTPException(status_code=400, detail=f"权限码不存在或已停用:{permission_code}")
|
||||
return permission
|
||||
|
||||
|
||||
def _employee_role_permission_maps(
|
||||
db: Session,
|
||||
employee_ids: list[int],
|
||||
) -> tuple[dict[int, User], dict[int, list[str]], dict[int, list[str]]]:
|
||||
if not employee_ids:
|
||||
return {}, {}, {}
|
||||
users = db.scalars(
|
||||
select(User)
|
||||
.where(
|
||||
User.employee_id.in_(employee_ids),
|
||||
User.status == "ACTIVE",
|
||||
)
|
||||
.order_by(User.id)
|
||||
).all()
|
||||
user_by_employee_id = {user.employee_id: user for user in users}
|
||||
if not users:
|
||||
return {}, {}, {}
|
||||
rows = db.execute(
|
||||
select(User.employee_id, Role.role_name, Permission.permission_code)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.outerjoin(RolePermission, RolePermission.role_id == Role.id)
|
||||
.outerjoin(
|
||||
Permission,
|
||||
and_(Permission.id == RolePermission.permission_id, Permission.status == "ACTIVE"),
|
||||
)
|
||||
.where(
|
||||
User.id.in_([user.id for user in users]),
|
||||
Role.status == "ACTIVE",
|
||||
)
|
||||
.order_by(User.employee_id, Role.id, Permission.permission_code)
|
||||
).all()
|
||||
role_map: dict[int, list[str]] = {}
|
||||
permission_map: dict[int, list[str]] = {}
|
||||
for employee_id, role_name, permission_code in rows:
|
||||
if role_name and role_name not in role_map.setdefault(employee_id, []):
|
||||
role_map[employee_id].append(role_name)
|
||||
if permission_code and permission_code not in permission_map.setdefault(employee_id, []):
|
||||
permission_map[employee_id].append(permission_code)
|
||||
return user_by_employee_id, role_map, permission_map
|
||||
|
||||
|
||||
def employee_has_permission(db: Session, employee_id: int, permission_code: str) -> bool:
|
||||
permission = _permission_by_code(db, permission_code)
|
||||
user = db.scalar(
|
||||
select(User).where(
|
||||
User.employee_id == employee_id,
|
||||
User.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
if not user:
|
||||
return False
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
return False
|
||||
if bool(user.is_super_admin):
|
||||
return True
|
||||
return bool(
|
||||
db.scalar(
|
||||
select(
|
||||
exists()
|
||||
.where(UserRole.user_id == user.id)
|
||||
.where(UserRole.role_id == Role.id)
|
||||
.where(Role.status == "ACTIVE")
|
||||
.where(RolePermission.role_id == Role.id)
|
||||
.where(RolePermission.permission_id == permission.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ensure_employee_has_permission(
|
||||
db: Session,
|
||||
employee_id: int | None,
|
||||
permission_code: str,
|
||||
field_label: str,
|
||||
*,
|
||||
required: bool = False,
|
||||
) -> None:
|
||||
if employee_id is None:
|
||||
if required:
|
||||
raise HTTPException(status_code=400, detail=f"{field_label}必填")
|
||||
return
|
||||
if employee_id <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"{field_label}不存在或已停用")
|
||||
permission = _permission_by_code(db, permission_code)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail=f"{field_label}不存在或已停用")
|
||||
if not employee_has_permission(db, employee_id, permission_code):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{field_label}必须选择有“{permission.permission_name}”权限的人员",
|
||||
)
|
||||
|
||||
|
||||
def list_employee_options(
|
||||
db: Session,
|
||||
permission_code: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> list[EmployeeOption]:
|
||||
permission = _permission_by_code(db, permission_code) if permission_code else None
|
||||
stmt = select(Employee).where(Employee.status == "ACTIVE")
|
||||
if permission:
|
||||
permitted_user_exists = (
|
||||
exists()
|
||||
.where(User.employee_id == Employee.id)
|
||||
.where(User.status == "ACTIVE")
|
||||
.where(
|
||||
or_(
|
||||
User.is_super_admin == 1,
|
||||
exists()
|
||||
.where(UserRole.user_id == User.id)
|
||||
.where(UserRole.role_id == Role.id)
|
||||
.where(Role.status == "ACTIVE")
|
||||
.where(RolePermission.role_id == Role.id)
|
||||
.where(RolePermission.permission_id == permission.id),
|
||||
)
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(permitted_user_exists)
|
||||
employees = sorted(
|
||||
db.scalars(stmt.order_by(Employee.id)).all(),
|
||||
key=_employee_name_sort_key,
|
||||
)[:limit]
|
||||
dept_map = {row.id: row.dept_name for row in db.scalars(select(Department)).all()}
|
||||
employee_ids = [employee.id for employee in employees]
|
||||
user_map, role_map, permission_map = _employee_role_permission_maps(db, employee_ids)
|
||||
return [
|
||||
EmployeeOption(
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.employee_name,
|
||||
dept_id=employee.dept_id,
|
||||
dept_name=dept_map.get(employee.dept_id),
|
||||
mobile=employee.mobile,
|
||||
job_title=employee.job_title,
|
||||
username=user_map[employee.id].username if employee.id in user_map else None,
|
||||
role_names=role_map.get(employee.id, []),
|
||||
permission_codes=permission_map.get(employee.id, []),
|
||||
has_system_user=employee.id in user_map,
|
||||
)
|
||||
for employee in employees
|
||||
]
|
||||
|
||||
|
||||
def _replace_user_roles(db: Session, user_id: int, role_ids: list[int]) -> None:
|
||||
roles = _roles_for_ids(db, role_ids)
|
||||
db.execute(delete(UserRole).where(UserRole.user_id == user_id))
|
||||
now = _now()
|
||||
for role in roles:
|
||||
db.add(UserRole(user_id=user_id, role_id=role.id, created_at=now))
|
||||
|
||||
|
||||
def _roles_for_ids(db: Session, role_ids: list[int]) -> list[Role]:
|
||||
unique_role_ids = list(dict.fromkeys(role_ids))
|
||||
roles = db.scalars(select(Role).where(Role.id.in_(unique_role_ids), Role.status == "ACTIVE")).all()
|
||||
missing = sorted(set(unique_role_ids) - {row.id for row in roles})
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"角色不存在或已停用:{missing}")
|
||||
return roles
|
||||
|
||||
|
||||
def create_system_user(db: Session, payload: SystemUserCreate) -> SystemUserRead:
|
||||
employee = db.get(Employee, payload.employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail="人员不存在或已停用")
|
||||
username = _normalize_phone(employee.mobile)
|
||||
if not username:
|
||||
raise HTTPException(status_code=400, detail="人员没有电话号码,不能创建系统账号")
|
||||
if db.scalar(select(User).where(User.employee_id == employee.id)):
|
||||
raise HTTPException(status_code=400, detail="该人员已经有系统账号")
|
||||
if db.scalar(select(User).where(User.username == username)):
|
||||
raise HTTPException(status_code=400, detail="该电话号码已经作为系统账号存在")
|
||||
_roles_for_ids(db, payload.role_ids)
|
||||
now = _now()
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(payload.password),
|
||||
employee_id=employee.id,
|
||||
dept_id=employee.dept_id,
|
||||
nickname=employee.employee_name,
|
||||
email=None,
|
||||
is_super_admin=0,
|
||||
last_login_at=None,
|
||||
status=payload.status,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
_replace_user_roles(db, user.id, payload.role_ids)
|
||||
db.commit()
|
||||
return _system_user_read(db, user)
|
||||
|
||||
|
||||
def update_system_user_roles(db: Session, user_id: int, role_ids: list[int]) -> SystemUserRead:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="系统用户不存在")
|
||||
_replace_user_roles(db, user.id, role_ids)
|
||||
user.updated_at = _now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return _system_user_read(db, user)
|
||||
|
||||
|
||||
def update_system_user_status(db: Session, user_id: int, status: str, current_user_id: int) -> SystemUserRead:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="系统用户不存在")
|
||||
if user.id == current_user_id and status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail="不能停用当前登录账号")
|
||||
user.status = status
|
||||
user.updated_at = _now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return _system_user_read(db, user)
|
||||
|
||||
|
||||
def reset_system_user_password(db: Session, user_id: int, password: str) -> SystemUserRead:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="系统用户不存在")
|
||||
user.password_hash = hash_password(password)
|
||||
user.updated_at = _now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return _system_user_read(db, user)
|
||||
Reference in New Issue
Block a user