Initial commit

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