Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import get_db
|
||||
from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole
|
||||
from app.schemas.database import AuthSession, LoginUser
|
||||
|
||||
PBKDF2_ITERATIONS = 600000
|
||||
|
||||
ROLE_TITLE_MAP = {
|
||||
"ADMIN": "总控驾驶舱",
|
||||
"SALES": "订单与客户协同",
|
||||
"PLANNER": "MRP与排产控制",
|
||||
"PURCHASER": "采购执行中心",
|
||||
"WAREHOUSE": "仓储作业中心",
|
||||
"SUPERVISOR": "车间调度中心",
|
||||
"OPERATOR": "现场报工中心",
|
||||
"FINANCE": "成本与财务中心",
|
||||
"EQUIP_ADMIN": "设备维护中心",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
user: User
|
||||
employee: Employee
|
||||
department: Department | None
|
||||
role_codes: list[str]
|
||||
role_names: list[str]
|
||||
permission_codes: list[str]
|
||||
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False, description="登录接口返回的 Bearer Token")
|
||||
|
||||
|
||||
def hash_password(password: str, salt: str | None = None) -> str:
|
||||
salt_bytes = os.urandom(16) if salt is None else salt.encode("utf-8")
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, PBKDF2_ITERATIONS)
|
||||
return (
|
||||
f"pbkdf2_sha256${PBKDF2_ITERATIONS}$"
|
||||
f"{base64.urlsafe_b64encode(salt_bytes).decode('utf-8')}$"
|
||||
f"{base64.urlsafe_b64encode(digest).decode('utf-8')}"
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, stored_hash: str) -> bool:
|
||||
try:
|
||||
algorithm, iterations, salt_b64, digest_b64 = stored_hash.split("$", 3)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
|
||||
salt = base64.urlsafe_b64decode(salt_b64.encode("utf-8"))
|
||||
expected = base64.urlsafe_b64decode(digest_b64.encode("utf-8"))
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
def get_auth_context(db: Session, username: str) -> AuthContext:
|
||||
stmt = (
|
||||
select(User, Employee, Department)
|
||||
.join(Employee, Employee.id == User.employee_id)
|
||||
.outerjoin(Department, Department.id == User.dept_id)
|
||||
.where(User.username == username, User.status == "ACTIVE")
|
||||
)
|
||||
row = db.execute(stmt).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
user, employee, department = row
|
||||
|
||||
role_rows = db.execute(
|
||||
select(Role.role_code, Role.role_name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user.id, Role.status == "ACTIVE")
|
||||
.order_by(Role.id)
|
||||
).all()
|
||||
role_codes = [row[0] for row in role_rows]
|
||||
role_names = [row[1] for row in role_rows]
|
||||
|
||||
permission_rows = db.execute(
|
||||
select(Permission.permission_code)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.join(UserRole, UserRole.role_id == RolePermission.role_id)
|
||||
.where(
|
||||
UserRole.user_id == user.id,
|
||||
Permission.status == "ACTIVE",
|
||||
)
|
||||
.group_by(Permission.permission_code)
|
||||
.order_by(Permission.permission_code)
|
||||
).all()
|
||||
permission_codes = [row[0] for row in permission_rows]
|
||||
if user.is_super_admin or "ADMIN" in role_codes:
|
||||
for permission_code in ("MENU_SYSTEM_EXTENSION", "MENU_SYSTEM_PERMISSION"):
|
||||
if permission_code not in permission_codes:
|
||||
permission_codes.append(permission_code)
|
||||
|
||||
return AuthContext(
|
||||
user=user,
|
||||
employee=employee,
|
||||
department=department,
|
||||
role_codes=role_codes,
|
||||
role_names=role_names,
|
||||
permission_codes=permission_codes,
|
||||
)
|
||||
|
||||
|
||||
def login_with_password(db: Session, username: str, password: str) -> AuthSession:
|
||||
context = get_auth_context(db, username)
|
||||
if not verify_password(password, context.user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
context.user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db.add(context.user)
|
||||
db.commit()
|
||||
return build_auth_session(context)
|
||||
|
||||
|
||||
def build_login_user(context: AuthContext) -> LoginUser:
|
||||
title = ROLE_TITLE_MAP.get(context.role_codes[0], "工业风管理台") if context.role_codes else "工业风管理台"
|
||||
return LoginUser(
|
||||
user_id=context.user.id,
|
||||
username=context.user.username,
|
||||
employee_id=context.employee.id,
|
||||
mobile=context.employee.mobile,
|
||||
display_name=context.user.nickname or context.employee.employee_name,
|
||||
title=title,
|
||||
dept_name=context.department.dept_name if context.department else None,
|
||||
role_codes=context.role_codes,
|
||||
role_names=context.role_names,
|
||||
permission_codes=context.permission_codes,
|
||||
)
|
||||
|
||||
|
||||
def build_auth_session(context: AuthContext) -> AuthSession:
|
||||
access_token, expires_at = create_access_token(context)
|
||||
return AuthSession(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_at=expires_at,
|
||||
**build_login_user(context).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
def next_employee_code(db: Session) -> str:
|
||||
total = db.scalar(select(func.count(Employee.id))) or 0
|
||||
return f"EMP-{int(total) + 1:04d}"
|
||||
|
||||
|
||||
def create_access_token(context: AuthContext) -> tuple[str, datetime]:
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(minutes=settings.auth_token_expire_minutes)
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {
|
||||
"sub": context.user.username,
|
||||
"uid": context.user.id,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
}
|
||||
encoded_header = _urlsafe_b64encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
|
||||
encoded_payload = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
signing_input = f"{encoded_header}.{encoded_payload}"
|
||||
signature = _sign_token(signing_input, settings.auth_secret_key)
|
||||
return f"{signing_input}.{signature}", expires_at
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
try:
|
||||
encoded_header, encoded_payload, signature = token.split(".", 2)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=401, detail="登录凭证无效") from exc
|
||||
|
||||
signing_input = f"{encoded_header}.{encoded_payload}"
|
||||
expected_signature = _sign_token(signing_input, settings.auth_secret_key)
|
||||
if not hmac.compare_digest(expected_signature, signature):
|
||||
raise HTTPException(status_code=401, detail="登录凭证无效")
|
||||
|
||||
try:
|
||||
payload = json.loads(_urlsafe_b64decode(encoded_payload).decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=401, detail="登录凭证无效") from exc
|
||||
|
||||
expires_at = payload.get("exp")
|
||||
username = payload.get("sub")
|
||||
if not isinstance(expires_at, int) or not username:
|
||||
raise HTTPException(status_code=401, detail="登录凭证无效")
|
||||
if expires_at <= int(datetime.now(timezone.utc).timestamp()):
|
||||
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def get_current_auth_context(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthContext:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
||||
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
return get_auth_context(db, str(payload["sub"]))
|
||||
|
||||
|
||||
def require_authenticated_user(
|
||||
context: Annotated[AuthContext, Depends(get_current_auth_context)],
|
||||
) -> AuthContext:
|
||||
return context
|
||||
|
||||
|
||||
def require_system_admin(
|
||||
context: Annotated[AuthContext, Depends(get_current_auth_context)],
|
||||
) -> AuthContext:
|
||||
if context.user.is_super_admin or "ADMIN" in context.role_codes:
|
||||
return context
|
||||
raise HTTPException(status_code=403, detail="仅系统管理员可访问系统管理")
|
||||
|
||||
|
||||
def _sign_token(signing_input: str, secret_key: str) -> str:
|
||||
digest = hmac.new(secret_key.encode("utf-8"), signing_input.encode("utf-8"), hashlib.sha256).digest()
|
||||
return _urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def _urlsafe_b64encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def _urlsafe_b64decode(data: str) -> bytes:
|
||||
padding = "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(f"{data}{padding}".encode("utf-8"))
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CONTACT_SEPARATOR = "、"
|
||||
MAX_CONTACTS = 3
|
||||
|
||||
|
||||
def _clean_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _entry_value(entry: Any, *keys: str) -> str:
|
||||
for key in keys:
|
||||
if isinstance(entry, dict):
|
||||
value = entry.get(key)
|
||||
else:
|
||||
value = getattr(entry, key, None)
|
||||
cleaned = _clean_text(value)
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return ""
|
||||
|
||||
|
||||
def _split_contacts(value: str | None) -> list[str]:
|
||||
text = _clean_text(value)
|
||||
if not text:
|
||||
return []
|
||||
return [part.strip() for part in re.split(r"[、,,;;\n]+", text) if part.strip()]
|
||||
|
||||
|
||||
def normalize_contacts(
|
||||
contacts: list[Any] | None,
|
||||
*,
|
||||
fallback_name: str | None = None,
|
||||
fallback_phone: str | None = None,
|
||||
max_contacts: int = MAX_CONTACTS,
|
||||
) -> list[dict[str, str]]:
|
||||
normalized: list[dict[str, str]] = []
|
||||
|
||||
for entry in contacts or []:
|
||||
contact_name = _entry_value(entry, "contact_name", "name")
|
||||
contact_phone = _entry_value(entry, "contact_phone", "phone")
|
||||
if not contact_name and not contact_phone:
|
||||
continue
|
||||
normalized.append({"contact_name": contact_name, "contact_phone": contact_phone})
|
||||
|
||||
if not normalized:
|
||||
contact_name = _clean_text(fallback_name)
|
||||
contact_phone = _clean_text(fallback_phone)
|
||||
if contact_name or contact_phone:
|
||||
normalized.append({"contact_name": contact_name, "contact_phone": contact_phone})
|
||||
|
||||
if len(normalized) > max_contacts:
|
||||
raise ValueError(f"联系人最多维护 {max_contacts} 位")
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def serialize_contacts(contacts: list[dict[str, str]]) -> tuple[str | None, str | None]:
|
||||
names = [item["contact_name"] for item in contacts if item.get("contact_name")]
|
||||
phones = [item["contact_phone"] for item in contacts if item.get("contact_phone")]
|
||||
return (
|
||||
CONTACT_SEPARATOR.join(names) if names else None,
|
||||
CONTACT_SEPARATOR.join(phones) if phones else None,
|
||||
)
|
||||
|
||||
|
||||
def parse_contacts(contact_name: str | None, contact_phone: str | None, *, max_contacts: int = MAX_CONTACTS) -> list[dict[str, str]]:
|
||||
names = _split_contacts(contact_name)
|
||||
phones = _split_contacts(contact_phone)
|
||||
total = min(max(len(names), len(phones)), max_contacts)
|
||||
return [
|
||||
{
|
||||
"contact_name": names[index] if index < len(names) else "",
|
||||
"contact_phone": phones[index] if index < len(phones) else "",
|
||||
}
|
||||
for index in range(total)
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.operations import to_decimal
|
||||
|
||||
|
||||
def normalize_logistics_fields(
|
||||
waybill_no: str | None,
|
||||
freight_amount: Any,
|
||||
*,
|
||||
required: bool,
|
||||
order_photo_url: str | None = None,
|
||||
freight_required: bool | None = None,
|
||||
) -> tuple[str | None, Decimal | None, str | None]:
|
||||
clean_waybill = str(waybill_no or "").strip()
|
||||
clean_photo_url = str(order_photo_url or "").strip()
|
||||
freight_missing = freight_amount is None or str(freight_amount).strip() == ""
|
||||
must_have_freight = required if freight_required is None else freight_required
|
||||
|
||||
if required and (not clean_waybill and not clean_photo_url) and freight_missing and freight_required is None:
|
||||
raise HTTPException(status_code=400, detail="该出入库业务必须填写运单号或上传辅助照片,并填写运费")
|
||||
if required and (not clean_waybill and not clean_photo_url):
|
||||
raise HTTPException(status_code=400, detail="该出入库业务必须填写运单号或上传辅助照片")
|
||||
if must_have_freight and freight_missing:
|
||||
raise HTTPException(status_code=400, detail="该出入库业务必须填写运费")
|
||||
if freight_missing:
|
||||
return clean_waybill or None, None, clean_photo_url or None
|
||||
|
||||
freight = to_decimal(freight_amount, "0.01")
|
||||
if freight < 0:
|
||||
raise HTTPException(status_code=400, detail="运费不能小于0")
|
||||
return clean_waybill or None, freight, clean_photo_url or None
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.dashboard import AlertItem, BoardSection, DashboardPayload, FlowStep, OverviewMetric
|
||||
from app.schemas.domain import CostInsight, InventoryLotSummary, MiniAppField, OperationCostLine, ProductSummary
|
||||
|
||||
|
||||
def get_dashboard_payload() -> DashboardPayload:
|
||||
return DashboardPayload(
|
||||
company="宁波嘉恒智能科技有限公司",
|
||||
overview=[
|
||||
OverviewMetric(
|
||||
code="sales_open",
|
||||
label="未交订单",
|
||||
value="8",
|
||||
unit="单",
|
||||
note="其中 3 单未来 7 天交期",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="mrp_gap",
|
||||
label="待采购材料缺口",
|
||||
value="59",
|
||||
unit="吨",
|
||||
highlight="warning",
|
||||
note="SAP440 500*600*2.5 需按 2 吨倍数采购",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="wip_orders",
|
||||
label="在制工单",
|
||||
value="6",
|
||||
unit="张",
|
||||
note="2 张存在工序卡滞",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="scrap_alerts",
|
||||
label="异常报废工序",
|
||||
value="2",
|
||||
unit="处",
|
||||
highlight="danger",
|
||||
note="冲压与焊接工序超阈值",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="waiting_ship",
|
||||
label="待发货成品",
|
||||
value="3200",
|
||||
unit="件",
|
||||
note="本体产品优先发货",
|
||||
),
|
||||
OverviewMetric(
|
||||
code="maintenance_due",
|
||||
label="设备待保养",
|
||||
value="3",
|
||||
unit="台",
|
||||
highlight="warning",
|
||||
note="含 1 台核心冲床",
|
||||
),
|
||||
],
|
||||
alerts=[
|
||||
AlertItem(
|
||||
level="danger",
|
||||
title="冲压工序报废率 4.8%",
|
||||
description="工单 MO-202604-003 的冲压工序高于允许报废率 2.0%,建议立即复核模具与板材批次。",
|
||||
owner="车间主管",
|
||||
action="检查模具并冻结异常批次",
|
||||
),
|
||||
AlertItem(
|
||||
level="warning",
|
||||
title="SAP440 材料仅剩 8 吨可用",
|
||||
description="按已排产工单计算,当前库存只能支撑 1.3 天,建议优先下达补料采购。",
|
||||
owner="采购员",
|
||||
action="生成补料采购单",
|
||||
),
|
||||
AlertItem(
|
||||
level="warning",
|
||||
title="冲床 JH-CY-03 保养到期",
|
||||
description="设备已连续运行 286 小时,超过设定保养上限 280 小时。",
|
||||
owner="设备管理员",
|
||||
action="创建保养工单",
|
||||
),
|
||||
],
|
||||
flow_board=[
|
||||
BoardSection(
|
||||
title="订单到采购",
|
||||
items=[
|
||||
FlowStep(
|
||||
step_code="sales",
|
||||
title="订单 SO-202604-001",
|
||||
status="done",
|
||||
summary="产品 本体,下单数量 10000 件,客户交期 2026-04-28。",
|
||||
quantity="10000 件",
|
||||
),
|
||||
FlowStep(
|
||||
step_code="mrp",
|
||||
title="MRP 物料需求",
|
||||
status="active",
|
||||
summary="计算出 SAP440 钢板理论需求 59000kg,按采购倍数建议下单 60000kg。",
|
||||
quantity="60 吨",
|
||||
),
|
||||
FlowStep(
|
||||
step_code="purchase",
|
||||
title="采购到货",
|
||||
status="queued",
|
||||
summary="已到货 60000kg,待入库复核与批次挂账。",
|
||||
quantity="60 吨",
|
||||
),
|
||||
],
|
||||
),
|
||||
BoardSection(
|
||||
title="领料到完工",
|
||||
items=[
|
||||
FlowStep(
|
||||
step_code="issue",
|
||||
title="生产出库",
|
||||
status="active",
|
||||
summary="第一批已领料 22000kg,第二批计划明日发料。",
|
||||
quantity="22 吨",
|
||||
),
|
||||
FlowStep(
|
||||
step_code="production",
|
||||
title="6 道工序报工",
|
||||
status="active",
|
||||
summary="冲压工序存在异常报废,焊接工序节拍偏慢。",
|
||||
quantity="在制 3800 件",
|
||||
),
|
||||
FlowStep(
|
||||
step_code="receipt",
|
||||
title="成品待入库",
|
||||
status="queued",
|
||||
summary="首批完工 3200 件,待质检确认后转成品库。",
|
||||
quantity="3200 件",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_product_overview_summary() -> ProductSummary:
|
||||
return ProductSummary(
|
||||
product_code="FG-BT-001",
|
||||
product_name="本体",
|
||||
material_name="SAP440 500*600*2.5 钢板",
|
||||
order_qty=10000,
|
||||
unit_weight_kg=5.2,
|
||||
gross_material_kg_per_piece=5.9,
|
||||
planned_material_weight_kg=59000,
|
||||
suggested_purchase_weight_kg=60000,
|
||||
purchase_multiple_weight_kg=2000,
|
||||
)
|
||||
|
||||
|
||||
def get_operation_costs() -> list[OperationCostLine]:
|
||||
return [
|
||||
OperationCostLine(
|
||||
seq_no=10,
|
||||
operation_name="开平",
|
||||
work_center="板材预处理区",
|
||||
std_labor_cost=0.12,
|
||||
std_machine_cost=0.18,
|
||||
allowed_scrap_rate=0.5,
|
||||
reported_good_qty=10000,
|
||||
reported_scrap_qty=12,
|
||||
abnormal=False,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=20,
|
||||
operation_name="冲压",
|
||||
work_center="冲压一区",
|
||||
std_labor_cost=0.35,
|
||||
std_machine_cost=0.42,
|
||||
allowed_scrap_rate=2.0,
|
||||
reported_good_qty=9580,
|
||||
reported_scrap_qty=420,
|
||||
abnormal=True,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=30,
|
||||
operation_name="去毛刺",
|
||||
work_center="后处理区",
|
||||
std_labor_cost=0.08,
|
||||
std_machine_cost=0.06,
|
||||
allowed_scrap_rate=0.8,
|
||||
reported_good_qty=9520,
|
||||
reported_scrap_qty=25,
|
||||
abnormal=False,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=40,
|
||||
operation_name="折弯",
|
||||
work_center="折弯一区",
|
||||
std_labor_cost=0.22,
|
||||
std_machine_cost=0.20,
|
||||
allowed_scrap_rate=1.0,
|
||||
reported_good_qty=9475,
|
||||
reported_scrap_qty=18,
|
||||
abnormal=False,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=50,
|
||||
operation_name="焊接",
|
||||
work_center="焊接二区",
|
||||
std_labor_cost=0.48,
|
||||
std_machine_cost=0.26,
|
||||
allowed_scrap_rate=1.5,
|
||||
reported_good_qty=9380,
|
||||
reported_scrap_qty=85,
|
||||
abnormal=True,
|
||||
),
|
||||
OperationCostLine(
|
||||
seq_no=60,
|
||||
operation_name="检验包装",
|
||||
work_center="成检包装区",
|
||||
std_labor_cost=0.15,
|
||||
std_machine_cost=0.04,
|
||||
allowed_scrap_rate=0.2,
|
||||
reported_good_qty=9320,
|
||||
reported_scrap_qty=8,
|
||||
abnormal=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_inventory_lots() -> list[InventoryLotSummary]:
|
||||
return [
|
||||
InventoryLotSummary(
|
||||
lot_no="RM-20260418-01",
|
||||
item_name="SAP440 500*600*2.5 钢板",
|
||||
warehouse="原材料仓",
|
||||
location="A-01-03",
|
||||
inbound_weight_kg=30000,
|
||||
remaining_weight_kg=12000,
|
||||
status="PARTIAL",
|
||||
),
|
||||
InventoryLotSummary(
|
||||
lot_no="RM-20260418-02",
|
||||
item_name="SAP440 500*600*2.5 钢板",
|
||||
warehouse="原材料仓",
|
||||
location="A-01-04",
|
||||
inbound_weight_kg=30000,
|
||||
remaining_weight_kg=26000,
|
||||
status="AVAILABLE",
|
||||
),
|
||||
InventoryLotSummary(
|
||||
lot_no="FG-20260420-01",
|
||||
item_name="本体",
|
||||
warehouse="成品仓",
|
||||
location="FG-02-01",
|
||||
inbound_weight_kg=16640,
|
||||
remaining_weight_kg=16640,
|
||||
status="WAIT_SHIP",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_mini_app_fields() -> list[MiniAppField]:
|
||||
return [
|
||||
MiniAppField(field_name="report_no", label="报工单号", required=True, description="报工流水唯一编号"),
|
||||
MiniAppField(field_name="work_order_no", label="工单号", required=True, description="与 ERP 工单主表关联"),
|
||||
MiniAppField(field_name="operation_seq", label="工序序号", required=True, description="对应工艺路线工序"),
|
||||
MiniAppField(field_name="employee_code", label="员工编码", required=True, description="报工人员账号"),
|
||||
MiniAppField(field_name="equipment_code", label="设备编码", required=False, description="采集实际作业设备"),
|
||||
MiniAppField(field_name="start_time", label="开始时间", required=True, description="工序开始加工时间"),
|
||||
MiniAppField(field_name="end_time", label="结束时间", required=True, description="工序结束加工时间"),
|
||||
MiniAppField(field_name="report_qty", label="报工数量", required=True, description="本次加工总数量"),
|
||||
MiniAppField(field_name="good_qty", label="合格数量", required=True, description="用于流转到下道工序"),
|
||||
MiniAppField(field_name="scrap_qty", label="报废数量", required=True, description="系统用于异常判定"),
|
||||
MiniAppField(field_name="rework_qty", label="返工数量", required=False, description="返工件单独计成本"),
|
||||
MiniAppField(field_name="scrap_reason", label="报废原因", required=False, description="用于工序和设备异常归因"),
|
||||
MiniAppField(field_name="downtime_minutes", label="停机分钟数", required=False, description="用于设备稼动率分析"),
|
||||
MiniAppField(field_name="remark", label="备注", required=False, description="现场异常补充说明"),
|
||||
]
|
||||
|
||||
|
||||
def get_cost_insights() -> list[CostInsight]:
|
||||
return [
|
||||
CostInsight(category="材料成本", amount=486000.0, ratio=72.4, note="含板材采购与运输"),
|
||||
CostInsight(category="人工成本", amount=82100.0, ratio=12.2, note="按工序报工工时归集"),
|
||||
CostInsight(category="设备成本", amount=46300.0, ratio=6.9, note="按设备开机工时分摊"),
|
||||
CostInsight(category="制造费用", amount=37200.0, ratio=5.5, note="房租、水电、后勤、管理"),
|
||||
CostInsight(category="异常损失", amount=20350.0, ratio=3.0, note="报废与返工"),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,406 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_FLOOR
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import Bom, BomItem, Item
|
||||
from app.models.operations import ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot
|
||||
from app.services.operations import to_decimal
|
||||
|
||||
|
||||
INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductionBatchLedgerSnapshot:
|
||||
production_ledger_id: int
|
||||
material_lot_no: str
|
||||
product_item_id: int
|
||||
material_item_id: int
|
||||
gross_weight_per_piece_kg: Decimal
|
||||
net_weight_per_piece_kg: Decimal
|
||||
total_issued_weight_kg: Decimal
|
||||
finished_inbound_qty: Decimal
|
||||
surplus_inbound_weight_kg: Decimal
|
||||
scrap_inbound_weight_kg: Decimal
|
||||
writeoff_weight_kg: Decimal
|
||||
outside_weight_kg: Decimal
|
||||
reference_finished_qty: Decimal
|
||||
used_material_weight_kg: Decimal
|
||||
theoretical_scrap_weight_kg: Decimal
|
||||
theoretical_surplus_weight_kg: Decimal
|
||||
|
||||
|
||||
def _round_weight(value: Decimal) -> Decimal:
|
||||
return to_decimal(value, "0.001")
|
||||
|
||||
|
||||
def _non_negative(value: Decimal) -> Decimal:
|
||||
return value if value > 0 else Decimal("0")
|
||||
|
||||
|
||||
def _append_txn(
|
||||
db: Session,
|
||||
*,
|
||||
ledger: ProductionBatchLedger,
|
||||
txn_type: str,
|
||||
qty_delta: Decimal = Decimal("0"),
|
||||
weight_delta_kg: Decimal = Decimal("0"),
|
||||
source_doc_type: str | None = None,
|
||||
source_doc_id: int | None = None,
|
||||
source_line_id: int | None = None,
|
||||
biz_time: datetime | None = None,
|
||||
operator_user_id: int | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedgerTxn:
|
||||
txn = ProductionBatchLedgerTxn(
|
||||
production_ledger_id=ledger.id,
|
||||
txn_type=txn_type,
|
||||
qty_delta=qty_delta,
|
||||
weight_delta_kg=weight_delta_kg,
|
||||
outside_weight_after_kg=to_decimal(ledger.outside_weight_kg),
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=biz_time or datetime.now(),
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(txn)
|
||||
db.flush()
|
||||
return txn
|
||||
|
||||
|
||||
def _get_bom_weight_profile(db: Session, product_item_id: int, material_item_id: int) -> tuple[Decimal, Decimal]:
|
||||
row = db.execute(
|
||||
select(
|
||||
BomItem.gross_weight_per_piece_kg.label("gross_weight_per_piece_kg"),
|
||||
BomItem.net_weight_per_piece_kg.label("net_weight_per_piece_kg"),
|
||||
)
|
||||
.join(Bom, Bom.id == BomItem.bom_id)
|
||||
.where(
|
||||
Bom.product_item_id == product_item_id,
|
||||
BomItem.material_item_id == material_item_id,
|
||||
Bom.status == "ACTIVE",
|
||||
)
|
||||
.order_by(Bom.is_default.desc(), BomItem.seq_no, BomItem.id)
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
gross = to_decimal(row["gross_weight_per_piece_kg"] if row else 0)
|
||||
net = to_decimal(row["net_weight_per_piece_kg"] if row else 0)
|
||||
if gross <= 0:
|
||||
product = db.get(Item, product_item_id)
|
||||
gross = to_decimal(product.unit_weight_kg if product else 0)
|
||||
if net <= 0 or net > gross:
|
||||
net = gross
|
||||
return gross, net
|
||||
|
||||
|
||||
def calculate_production_batch_ledger_snapshot(
|
||||
db: Session,
|
||||
production_ledger_id: int,
|
||||
*,
|
||||
smart_reporting_enabled: bool,
|
||||
selected_finished_qty: Decimal | None = None,
|
||||
) -> ProductionBatchLedgerSnapshot:
|
||||
_ = smart_reporting_enabled
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
gross, net = _get_bom_weight_profile(db, ledger.product_item_id, ledger.material_item_id)
|
||||
total_issued = to_decimal(ledger.total_issued_weight_kg)
|
||||
finished_qty = to_decimal(ledger.finished_inbound_qty)
|
||||
basis_qty = finished_qty + to_decimal(selected_finished_qty)
|
||||
reference_finished_qty = Decimal("0")
|
||||
if gross > 0:
|
||||
reference_finished_qty = (total_issued / gross).to_integral_value(rounding=ROUND_FLOOR)
|
||||
used_weight = _round_weight(basis_qty * gross)
|
||||
theoretical_scrap = _round_weight(_non_negative(basis_qty * _non_negative(gross - net) - to_decimal(ledger.scrap_inbound_weight_kg)))
|
||||
theoretical_surplus = _round_weight(_non_negative(total_issued - used_weight - to_decimal(ledger.surplus_inbound_weight_kg)))
|
||||
return ProductionBatchLedgerSnapshot(
|
||||
production_ledger_id=ledger.id,
|
||||
material_lot_no=ledger.material_lot_no,
|
||||
product_item_id=ledger.product_item_id,
|
||||
material_item_id=ledger.material_item_id,
|
||||
gross_weight_per_piece_kg=gross,
|
||||
net_weight_per_piece_kg=net,
|
||||
total_issued_weight_kg=total_issued,
|
||||
finished_inbound_qty=finished_qty,
|
||||
surplus_inbound_weight_kg=to_decimal(ledger.surplus_inbound_weight_kg),
|
||||
scrap_inbound_weight_kg=to_decimal(ledger.scrap_inbound_weight_kg),
|
||||
writeoff_weight_kg=to_decimal(ledger.writeoff_weight_kg),
|
||||
outside_weight_kg=to_decimal(ledger.outside_weight_kg),
|
||||
reference_finished_qty=reference_finished_qty,
|
||||
used_material_weight_kg=used_weight,
|
||||
theoretical_scrap_weight_kg=theoretical_scrap,
|
||||
theoretical_surplus_weight_kg=theoretical_surplus,
|
||||
)
|
||||
|
||||
|
||||
def post_finished_inbound_to_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
production_ledger_id: int,
|
||||
finished_qty: Decimal,
|
||||
operator_user_id: int | None,
|
||||
source_doc_type: str | None,
|
||||
source_doc_id: int | None,
|
||||
source_line_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
qty = to_decimal(finished_qty)
|
||||
if qty < 0:
|
||||
raise HTTPException(status_code=400, detail="成品入库数量不能小于0")
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
gross, _ = _get_bom_weight_profile(db, ledger.product_item_id, ledger.material_item_id)
|
||||
used_weight = _round_weight(qty * gross)
|
||||
ledger.finished_inbound_qty = to_decimal(ledger.finished_inbound_qty) + qty
|
||||
ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - used_weight))
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="成品入库",
|
||||
qty_delta=qty,
|
||||
weight_delta_kg=-used_weight,
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=biz_time,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "成品入库联动生产台账",
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def post_surplus_inbound_to_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
production_ledger_id: int,
|
||||
surplus_weight_kg: Decimal,
|
||||
operator_user_id: int | None,
|
||||
source_doc_type: str | None,
|
||||
source_doc_id: int | None,
|
||||
source_line_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
weight = _round_weight(to_decimal(surplus_weight_kg))
|
||||
if weight < 0:
|
||||
raise HTTPException(status_code=400, detail="余料入库重量不能小于0")
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
ledger.surplus_inbound_weight_kg = _round_weight(to_decimal(ledger.surplus_inbound_weight_kg) + weight)
|
||||
ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - weight))
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="生产余料入库",
|
||||
weight_delta_kg=-weight,
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=biz_time,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "生产余料入库联动生产台账",
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def post_scrap_inbound_to_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
production_ledger_id: int,
|
||||
scrap_weight_kg: Decimal,
|
||||
operator_user_id: int | None,
|
||||
source_doc_type: str | None,
|
||||
source_doc_id: int | None,
|
||||
source_line_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
weight = _round_weight(to_decimal(scrap_weight_kg))
|
||||
if weight < 0:
|
||||
raise HTTPException(status_code=400, detail="废料入库重量不能小于0")
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
ledger.scrap_inbound_weight_kg = _round_weight(to_decimal(ledger.scrap_inbound_weight_kg) + weight)
|
||||
ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - weight))
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="生产废料入库",
|
||||
weight_delta_kg=-weight,
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=biz_time,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "生产废料入库联动生产台账",
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def _get_production_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
material_lot_id: int,
|
||||
product_item_id: int,
|
||||
) -> ProductionBatchLedger | None:
|
||||
return db.scalar(
|
||||
select(ProductionBatchLedger)
|
||||
.where(
|
||||
ProductionBatchLedger.material_lot_id == material_lot_id,
|
||||
ProductionBatchLedger.product_item_id == product_item_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def record_production_issue_to_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
material_lot_id: int,
|
||||
product_item_id: int,
|
||||
issued_weight_kg: Decimal,
|
||||
operator_user_id: int | None,
|
||||
source_doc_type: str | None,
|
||||
source_doc_id: int | None,
|
||||
source_line_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
issued_weight = _round_weight(to_decimal(issued_weight_kg))
|
||||
if issued_weight <= 0:
|
||||
raise HTTPException(status_code=400, detail="生产出库重量必须大于0")
|
||||
lot = db.get(StockLot, material_lot_id)
|
||||
if not lot:
|
||||
raise HTTPException(status_code=404, detail="材料库存批次号不存在")
|
||||
product = db.get(Item, product_item_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
now = biz_time or datetime.now()
|
||||
ledger = _get_production_ledger(db, material_lot_id=material_lot_id, product_item_id=product_item_id)
|
||||
if ledger and str(ledger.status or "") == "锁单":
|
||||
ledger.status = "在生产"
|
||||
ledger.miniapp_selectable = 1
|
||||
ledger.reopened_at = now
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="重新开工",
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark="锁单后再次生产出库,生产台账重新进入在生产状态",
|
||||
)
|
||||
|
||||
if not ledger:
|
||||
ledger = ProductionBatchLedger(
|
||||
material_lot_id=material_lot_id,
|
||||
material_lot_no=lot.lot_no,
|
||||
material_item_id=lot.item_id,
|
||||
product_item_id=product_item_id,
|
||||
status="在生产",
|
||||
miniapp_selectable=1,
|
||||
total_issued_weight_kg=Decimal("0"),
|
||||
outside_weight_kg=Decimal("0"),
|
||||
finished_inbound_qty=Decimal("0"),
|
||||
surplus_inbound_weight_kg=Decimal("0"),
|
||||
scrap_inbound_weight_kg=Decimal("0"),
|
||||
writeoff_weight_kg=Decimal("0"),
|
||||
first_issue_time=now,
|
||||
last_issue_time=now,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
|
||||
ledger.status = "在生产"
|
||||
ledger.miniapp_selectable = 1
|
||||
ledger.total_issued_weight_kg = _round_weight(to_decimal(ledger.total_issued_weight_kg) + issued_weight)
|
||||
ledger.outside_weight_kg = _round_weight(to_decimal(ledger.outside_weight_kg) + issued_weight)
|
||||
ledger.last_issue_time = now
|
||||
if not ledger.first_issue_time:
|
||||
ledger.first_issue_time = now
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="生产出库",
|
||||
weight_delta_kg=issued_weight,
|
||||
source_doc_type=source_doc_type,
|
||||
source_doc_id=source_doc_id,
|
||||
source_line_id=source_line_id,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "生产出库累计到生产台账",
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def lock_production_batch_ledger(
|
||||
db: Session,
|
||||
*,
|
||||
production_ledger_id: int,
|
||||
operator_user_id: int | None,
|
||||
biz_time: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> ProductionBatchLedger:
|
||||
ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||||
if not ledger:
|
||||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||||
|
||||
now = biz_time or datetime.now()
|
||||
remaining = _round_weight(to_decimal(ledger.outside_weight_kg))
|
||||
if remaining > 0:
|
||||
ledger.writeoff_weight_kg = _round_weight(to_decimal(ledger.writeoff_weight_kg) + remaining)
|
||||
ledger.outside_weight_kg = Decimal("0")
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="结单核销",
|
||||
weight_delta_kg=-remaining,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "该批材料结单时确认库外剩余重量已无现场存量",
|
||||
)
|
||||
|
||||
ledger.status = "锁单"
|
||||
ledger.miniapp_selectable = 0
|
||||
ledger.locked_at = now
|
||||
ledger.lock_count = int(ledger.lock_count or 0) + 1
|
||||
db.add(ledger)
|
||||
db.flush()
|
||||
_append_txn(
|
||||
db,
|
||||
ledger=ledger,
|
||||
txn_type="该批材料结单",
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=remark or "该批材料已结单,小程序不再显示该材料库存批次号",
|
||||
)
|
||||
return ledger
|
||||
@@ -0,0 +1,553 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_FLOOR
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import BomItem, Item, Warehouse
|
||||
from app.models.operations import (
|
||||
CompletionReceipt,
|
||||
CompletionReceiptItem,
|
||||
InventoryTxn,
|
||||
StockLot,
|
||||
WorkOrder,
|
||||
WorkOrderMaterial,
|
||||
WorkOrderMaterialIssue,
|
||||
)
|
||||
from app.schemas.operations import (
|
||||
ProductionWorkOrderInboundCreate,
|
||||
ProductionWorkOrderInboundPreviewRead,
|
||||
ProductionWorkOrderInboundRead,
|
||||
)
|
||||
from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, generate_document_archive
|
||||
from app.services.operations import create_inventory_txn, to_decimal, upsert_stock_balance
|
||||
from app.services.production_work_order_ledger import get_work_order_limit_snapshot
|
||||
from app.services.sales_planning import code_token, next_doc_no
|
||||
from app.services.stocktake import ensure_warehouses_unlocked
|
||||
|
||||
|
||||
DEVIATION_RATE = Decimal("0.15")
|
||||
FINISHED_QTY_UPPER_MULTIPLIER = Decimal("1.15")
|
||||
|
||||
|
||||
def _production_document_batch_no(now: datetime) -> str:
|
||||
return f"生产结算{now.strftime('%Y%m%d%H%M%S')}{uuid4().int % 1_000_000:06d}"
|
||||
|
||||
|
||||
def _with_document_batch_no(remark: str, batch_no: str | None) -> str:
|
||||
if not batch_no:
|
||||
return remark
|
||||
return f"{remark};单据批次号:{batch_no}"
|
||||
|
||||
|
||||
def _round_decimal(value: Decimal, places: str = "0.001") -> Decimal:
|
||||
return to_decimal(value, places)
|
||||
|
||||
|
||||
def _non_negative(value: Decimal) -> Decimal:
|
||||
return max(to_decimal(value), Decimal("0"))
|
||||
|
||||
|
||||
def _deviation_exceeds_limit(actual: Decimal, theoretical: Decimal) -> bool:
|
||||
actual = to_decimal(actual)
|
||||
theoretical = to_decimal(theoretical)
|
||||
if theoretical == 0:
|
||||
return actual != 0
|
||||
return abs(actual - theoretical) / abs(theoretical) > DEVIATION_RATE
|
||||
|
||||
|
||||
def _floor_qty(value: Decimal) -> Decimal:
|
||||
return to_decimal(value).to_integral_value(rounding=ROUND_FLOOR)
|
||||
|
||||
|
||||
def _reference_finished_qty(issued_weight: Decimal, gross_weight: Decimal) -> Decimal:
|
||||
issued_weight = to_decimal(issued_weight)
|
||||
gross_weight = to_decimal(gross_weight)
|
||||
if issued_weight <= 0 or gross_weight <= 0:
|
||||
return Decimal("0")
|
||||
return _floor_qty(issued_weight / gross_weight)
|
||||
|
||||
|
||||
def _finished_qty_exceeds_reference_limit(total_finished_qty: Decimal, reference_finished_qty: Decimal) -> bool:
|
||||
total_finished_qty = to_decimal(total_finished_qty)
|
||||
reference_finished_qty = to_decimal(reference_finished_qty)
|
||||
if reference_finished_qty <= 0:
|
||||
return total_finished_qty > 0
|
||||
return total_finished_qty > reference_finished_qty * FINISHED_QTY_UPPER_MULTIPLIER
|
||||
|
||||
|
||||
def _remark_with_deviation(base: str, deviation_remark: str | None) -> str:
|
||||
deviation = str(deviation_remark or "").strip()
|
||||
if not deviation:
|
||||
return base
|
||||
return f"{base};偏差说明:{deviation}"
|
||||
|
||||
|
||||
def _source_issue(db: Session, work_order_id: int) -> WorkOrderMaterialIssue:
|
||||
issues = db.scalars(
|
||||
select(WorkOrderMaterialIssue)
|
||||
.where(WorkOrderMaterialIssue.work_order_id == work_order_id)
|
||||
.order_by(WorkOrderMaterialIssue.id)
|
||||
).all()
|
||||
if not issues:
|
||||
raise HTTPException(status_code=400, detail="该工单没有材料库存批次领用记录,不能进行生产工单入库")
|
||||
source_lot_ids = {int(issue.source_lot_id) for issue in issues if issue.source_lot_id is not None}
|
||||
if not source_lot_ids:
|
||||
raise HTTPException(status_code=400, detail="该工单没有材料来源库存批次,不能进行生产工单入库")
|
||||
if len(source_lot_ids) > 1:
|
||||
raise HTTPException(status_code=400, detail="该工单存在多个材料来源库存批次,不能进行生产工单入库")
|
||||
source_lot_id = next(iter(source_lot_ids))
|
||||
return next(issue for issue in issues if issue.source_lot_id is not None and int(issue.source_lot_id) == source_lot_id)
|
||||
|
||||
|
||||
def _get_work_order(db: Session, work_order_id: int) -> WorkOrder:
|
||||
work_order = db.get(WorkOrder, work_order_id)
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=404, detail="生产工单不存在")
|
||||
if str(work_order.status or "").upper() == "SETTLED":
|
||||
raise HTTPException(status_code=400, detail="该生产工单已结单,不能进行生产工单入库")
|
||||
return work_order
|
||||
|
||||
|
||||
def _get_warehouse(db: Session, warehouse_type: str) -> Warehouse:
|
||||
warehouse = db.scalar(
|
||||
select(Warehouse)
|
||||
.where(
|
||||
Warehouse.warehouse_type == warehouse_type,
|
||||
Warehouse.status == "ACTIVE",
|
||||
)
|
||||
.order_by(Warehouse.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not warehouse:
|
||||
warehouse_type_name = {"FINISHED": "成品", "SCRAP": "废料"}.get(warehouse_type, "目标")
|
||||
raise HTTPException(status_code=400, detail=f"缺少启用的{warehouse_type_name}仓库,不能进行生产工单入库")
|
||||
return warehouse
|
||||
|
||||
|
||||
def _get_bom_item(db: Session, work_order: WorkOrder, material_item_id: int) -> BomItem:
|
||||
bom_item = db.scalar(
|
||||
select(BomItem)
|
||||
.where(
|
||||
BomItem.bom_id == work_order.bom_id,
|
||||
BomItem.material_item_id == material_item_id,
|
||||
)
|
||||
.order_by(BomItem.seq_no, BomItem.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not bom_item:
|
||||
raise HTTPException(status_code=400, detail="工单需用清单缺少领用材料,不能进行生产工单入库")
|
||||
return bom_item
|
||||
|
||||
|
||||
def _build_receipt_no(db: Session, work_order: WorkOrder, now: datetime) -> str:
|
||||
work_order_part = code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18)
|
||||
return next_doc_no(db, CompletionReceipt, "receipt_no", f"WOI-NB-{now.strftime('%Y%m%d')}-{work_order_part}")
|
||||
|
||||
|
||||
def _build_lot_no(db: Session, prefix: str, work_order: WorkOrder, item: Item, now: datetime) -> str:
|
||||
item_part = code_token(item.item_code or item.item_name, f"ITEM{item.id:04d}", 12)
|
||||
work_order_part = code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 10)
|
||||
return next_doc_no(db, StockLot, "lot_no", f"{prefix}-NB-{now.strftime('%Y%m%d')}-{item_part}-{work_order_part}")
|
||||
|
||||
|
||||
def _append_settle_remark(work_order: WorkOrder, deviation_remark: str | None, remark: str | None) -> None:
|
||||
parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()]
|
||||
parts.append("生产工单入库已结单")
|
||||
if str(deviation_remark or "").strip():
|
||||
parts.append(f"偏差说明:{str(deviation_remark).strip()}")
|
||||
if str(remark or "").strip():
|
||||
parts.append(f"入库说明:{str(remark).strip()}")
|
||||
work_order.remark = ";".join(dict.fromkeys(parts))[-255:]
|
||||
|
||||
|
||||
def preview_production_work_order_inbound(
|
||||
db: Session,
|
||||
*,
|
||||
work_order_id: int,
|
||||
finished_qty: float | Decimal | None = None,
|
||||
) -> ProductionWorkOrderInboundPreviewRead:
|
||||
work_order = _get_work_order(db, work_order_id)
|
||||
issue = _source_issue(db, work_order_id)
|
||||
source_lot = db.get(StockLot, issue.source_lot_id)
|
||||
product = db.get(Item, work_order.product_item_id)
|
||||
material = db.get(Item, issue.material_item_id)
|
||||
if not source_lot:
|
||||
raise HTTPException(status_code=400, detail="来源库存批次不存在,不能进行生产工单入库")
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail="工单产品不存在,不能进行生产工单入库")
|
||||
if not material:
|
||||
raise HTTPException(status_code=400, detail="领用材料不存在,不能进行生产工单入库")
|
||||
|
||||
snapshot = get_work_order_limit_snapshot(db, work_order_id)
|
||||
bom_item = _get_bom_item(db, work_order, issue.material_item_id)
|
||||
gross = to_decimal(bom_item.gross_weight_per_piece_kg or snapshot.gross_weight_per_piece_kg)
|
||||
net = to_decimal(bom_item.net_weight_per_piece_kg or snapshot.net_weight_per_piece_kg)
|
||||
if gross <= 0:
|
||||
gross = to_decimal(snapshot.gross_weight_per_piece_kg)
|
||||
if net <= 0:
|
||||
net = to_decimal(snapshot.net_weight_per_piece_kg)
|
||||
if gross <= 0:
|
||||
raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重,不能进行生产工单入库")
|
||||
if gross < net:
|
||||
net = gross
|
||||
|
||||
reference_finished_qty = _reference_finished_qty(to_decimal(snapshot.issued_weight_kg), gross)
|
||||
finished_received_qty = to_decimal(snapshot.finished_received_qty)
|
||||
remaining_reference_finished_qty = _non_negative(reference_finished_qty - finished_received_qty)
|
||||
selected_finished_qty = to_decimal(finished_qty) if finished_qty is not None else remaining_reference_finished_qty
|
||||
if selected_finished_qty < 0:
|
||||
raise HTTPException(status_code=400, detail="成品入库数量不能小于0")
|
||||
total_finished_qty = finished_received_qty + selected_finished_qty
|
||||
theoretical_surplus = _non_negative(
|
||||
to_decimal(snapshot.issued_weight_kg) - to_decimal(snapshot.returned_material_weight_kg) - total_finished_qty * gross
|
||||
)
|
||||
theoretical_scrap = _non_negative(to_decimal(snapshot.max_scrap_inbound_weight_kg))
|
||||
|
||||
return ProductionWorkOrderInboundPreviewRead(
|
||||
work_order_id=work_order.id,
|
||||
work_order_no=work_order.work_order_no,
|
||||
product_item_id=product.id,
|
||||
product_code=product.item_code,
|
||||
product_name=product.item_name,
|
||||
source_lot_id=source_lot.id,
|
||||
source_lot_no=source_lot.lot_no,
|
||||
material_item_id=material.id,
|
||||
material_code=material.item_code,
|
||||
material_name=material.item_name,
|
||||
gross_weight_per_piece_kg=float(_round_decimal(gross)),
|
||||
net_weight_per_piece_kg=float(_round_decimal(net)),
|
||||
issued_weight_kg=float(_round_decimal(to_decimal(snapshot.issued_weight_kg))),
|
||||
returned_weight_kg=float(_round_decimal(to_decimal(snapshot.returned_material_weight_kg))),
|
||||
final_operation_good_qty=float(_round_decimal(to_decimal(snapshot.final_operation_good_qty))),
|
||||
finished_inbound_qty=float(_round_decimal(to_decimal(snapshot.finished_received_qty))),
|
||||
reported_scrap_qty=float(_round_decimal(to_decimal(snapshot.total_operation_scrap_qty))),
|
||||
scrap_inbound_weight_kg=float(_round_decimal(to_decimal(snapshot.scrap_inbound_weight_kg))),
|
||||
reference_finished_qty=float(_round_decimal(reference_finished_qty)),
|
||||
remaining_reference_finished_qty=float(_round_decimal(remaining_reference_finished_qty)),
|
||||
finished_qty_upper_limit=float(_round_decimal(reference_finished_qty * FINISHED_QTY_UPPER_MULTIPLIER)),
|
||||
finished_qty=float(_round_decimal(selected_finished_qty)),
|
||||
theoretical_surplus_weight_kg=float(_round_decimal(theoretical_surplus)),
|
||||
theoretical_scrap_weight_kg=float(_round_decimal(theoretical_scrap)),
|
||||
)
|
||||
|
||||
|
||||
def create_production_work_order_inbound(
|
||||
db: Session,
|
||||
*,
|
||||
payload: ProductionWorkOrderInboundCreate,
|
||||
operator_user_id: int | None,
|
||||
) -> ProductionWorkOrderInboundRead:
|
||||
work_order = _get_work_order(db, payload.work_order_id)
|
||||
issue = _source_issue(db, payload.work_order_id)
|
||||
source_lot = db.get(StockLot, issue.source_lot_id)
|
||||
product = db.get(Item, work_order.product_item_id)
|
||||
material = db.get(Item, issue.material_item_id)
|
||||
if not source_lot or not product or not material:
|
||||
raise HTTPException(status_code=400, detail="工单入库来源数据不完整")
|
||||
|
||||
finished_qty = to_decimal(payload.finished_qty)
|
||||
surplus_weight = to_decimal(payload.surplus_weight_kg)
|
||||
scrap_weight = to_decimal(payload.scrap_weight_kg)
|
||||
if min(finished_qty, surplus_weight, scrap_weight) < 0:
|
||||
raise HTTPException(status_code=400, detail="入库数量或重量不能小于0")
|
||||
if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0:
|
||||
raise HTTPException(status_code=400, detail="生产工单入库至少需要填写成品、余料或废料中的一项")
|
||||
|
||||
preview = preview_production_work_order_inbound(db, work_order_id=payload.work_order_id, finished_qty=finished_qty)
|
||||
theoretical_surplus = to_decimal(preview.theoretical_surplus_weight_kg)
|
||||
theoretical_scrap = to_decimal(preview.theoretical_scrap_weight_kg)
|
||||
total_finished_qty = to_decimal(preview.finished_inbound_qty) + finished_qty
|
||||
reference_finished_qty = to_decimal(preview.reference_finished_qty)
|
||||
if _finished_qty_exceeds_reference_limit(total_finished_qty, reference_finished_qty) and not str(payload.deviation_remark or "").strip():
|
||||
raise HTTPException(status_code=400, detail="成品入库数量超过本工单参考生产数量上限15%,请填写偏差说明")
|
||||
if (
|
||||
_deviation_exceeds_limit(surplus_weight, theoretical_surplus)
|
||||
or _deviation_exceeds_limit(scrap_weight, theoretical_scrap)
|
||||
) and not str(payload.deviation_remark or "").strip():
|
||||
raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明")
|
||||
if payload.settle_work_order and not payload.settle_confirmed:
|
||||
raise HTTPException(status_code=400, detail="结单前必须确认本次入库明细及结单影响")
|
||||
|
||||
now = datetime.now()
|
||||
bom_item = _get_bom_item(db, work_order, issue.material_item_id)
|
||||
gross = to_decimal(bom_item.gross_weight_per_piece_kg or preview.gross_weight_per_piece_kg)
|
||||
product_weight = to_decimal(product.unit_weight_kg or bom_item.net_weight_per_piece_kg or preview.net_weight_per_piece_kg)
|
||||
finished_warehouse = _get_warehouse(db, "FINISHED") if finished_qty > 0 else None
|
||||
scrap_warehouse = _get_warehouse(db, "SCRAP") if scrap_weight > 0 else None
|
||||
material_row = db.get(WorkOrderMaterial, issue.work_order_material_id)
|
||||
if surplus_weight > 0 and not material_row:
|
||||
raise HTTPException(status_code=400, detail="工单领料明细不存在,不能进行余料入库")
|
||||
locked_check_warehouse_ids: set[int] = set()
|
||||
if finished_qty > 0 and finished_warehouse:
|
||||
locked_check_warehouse_ids.add(int(finished_warehouse.id))
|
||||
if surplus_weight > 0:
|
||||
locked_check_warehouse_ids.add(int(source_lot.warehouse_id))
|
||||
if scrap_weight > 0 and scrap_warehouse:
|
||||
locked_check_warehouse_ids.add(int(scrap_warehouse.id))
|
||||
ensure_warehouses_unlocked(db, locked_check_warehouse_ids, "生产工单入库")
|
||||
|
||||
finished_lot_id = surplus_lot_id = scrap_lot_id = None
|
||||
finished_txn_id = surplus_txn_id = scrap_txn_id = None
|
||||
document_batch_no = _production_document_batch_no(now)
|
||||
|
||||
if finished_qty > 0 and finished_warehouse:
|
||||
receipt = CompletionReceipt(
|
||||
receipt_no=_build_receipt_no(db, work_order, now),
|
||||
work_order_id=work_order.id,
|
||||
warehouse_id=finished_warehouse.id,
|
||||
receipt_time=now,
|
||||
receiver_employee_id=None,
|
||||
status="POSTED",
|
||||
remark=_remark_with_deviation(payload.remark or "生产工单入库", payload.deviation_remark),
|
||||
)
|
||||
db.add(receipt)
|
||||
db.flush()
|
||||
lot_no = _build_lot_no(db, "FGI", work_order, product, now)
|
||||
receipt_weight = finished_qty * product_weight
|
||||
unit_cost = to_decimal(to_decimal(source_lot.unit_cost) * gross, "0.0001")
|
||||
material_summary = f"{material.item_name}:{source_lot.lot_no}"
|
||||
receipt_item = CompletionReceiptItem(
|
||||
completion_receipt_id=receipt.id,
|
||||
line_no=1,
|
||||
product_item_id=product.id,
|
||||
location_id=None,
|
||||
lot_no=lot_no,
|
||||
source_material_lot_id=source_lot.id,
|
||||
source_material_sub_batch_no=issue.material_sub_batch_no,
|
||||
source_material_summary=material_summary,
|
||||
receipt_qty=finished_qty,
|
||||
receipt_weight_kg=receipt_weight,
|
||||
unit_cost=unit_cost,
|
||||
status="POSTED",
|
||||
remark=_remark_with_deviation(payload.remark or "生产工单入库", payload.deviation_remark),
|
||||
)
|
||||
db.add(receipt_item)
|
||||
db.flush()
|
||||
finished_lot = StockLot(
|
||||
lot_no=lot_no,
|
||||
parent_lot_id=source_lot.id,
|
||||
lot_role="FINISHED_FROM_RAW_BATCH",
|
||||
material_sub_batch_no=None,
|
||||
item_id=product.id,
|
||||
warehouse_id=finished_warehouse.id,
|
||||
location_id=None,
|
||||
source_doc_type="FG_RECEIPT",
|
||||
source_doc_id=receipt.id,
|
||||
source_line_id=receipt_item.id,
|
||||
source_material_lot_id=source_lot.id,
|
||||
source_material_sub_batch_no=issue.material_sub_batch_no,
|
||||
source_material_summary=material_summary,
|
||||
inbound_qty=finished_qty,
|
||||
inbound_weight_kg=receipt_weight,
|
||||
remaining_qty=finished_qty,
|
||||
remaining_weight_kg=receipt_weight,
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=unit_cost,
|
||||
production_date=now.date(),
|
||||
expire_date=None,
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
remark=_with_document_batch_no(
|
||||
_remark_with_deviation("生产工单入库;来源库存批次已记录", payload.deviation_remark),
|
||||
document_batch_no,
|
||||
),
|
||||
)
|
||||
db.add(finished_lot)
|
||||
db.flush()
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=product.id,
|
||||
warehouse_id=finished_warehouse.id,
|
||||
location_id=None,
|
||||
qty_delta=finished_qty,
|
||||
weight_delta=receipt_weight,
|
||||
available_qty_delta=finished_qty,
|
||||
available_weight_delta=receipt_weight,
|
||||
unit_cost=unit_cost,
|
||||
)
|
||||
txn = create_inventory_txn(
|
||||
db,
|
||||
txn_type="FG_IN",
|
||||
item_id=product.id,
|
||||
warehouse_id=finished_warehouse.id,
|
||||
location_id=None,
|
||||
lot_id=finished_lot.id,
|
||||
qty_change=finished_qty,
|
||||
weight_change=receipt_weight,
|
||||
unit_cost=unit_cost,
|
||||
source_doc_type="FG_RECEIPT",
|
||||
source_doc_id=receipt.id,
|
||||
source_line_id=receipt_item.id,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=_with_document_batch_no(_remark_with_deviation("生产工单入库", payload.deviation_remark), document_batch_no),
|
||||
amount_basis="QTY",
|
||||
)
|
||||
finished_lot_id = finished_lot.id
|
||||
finished_txn_id = txn.id
|
||||
work_order.finished_qty = to_decimal(work_order.finished_qty) + finished_qty
|
||||
|
||||
if surplus_weight > 0:
|
||||
source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight
|
||||
source_lot.status = "AVAILABLE"
|
||||
db.add(source_lot)
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=material.id,
|
||||
warehouse_id=source_lot.warehouse_id,
|
||||
location_id=source_lot.location_id,
|
||||
qty_delta=Decimal("0"),
|
||||
weight_delta=surplus_weight,
|
||||
available_qty_delta=Decimal("0"),
|
||||
available_weight_delta=surplus_weight,
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
)
|
||||
txn = create_inventory_txn(
|
||||
db,
|
||||
txn_type="PRODUCTION_SURPLUS_IN",
|
||||
item_id=material.id,
|
||||
warehouse_id=source_lot.warehouse_id,
|
||||
location_id=source_lot.location_id,
|
||||
lot_id=source_lot.id,
|
||||
qty_change=Decimal("0"),
|
||||
weight_change=surplus_weight,
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
source_doc_type="WORK_ORDER",
|
||||
source_doc_id=work_order.id,
|
||||
source_line_id=material_row.id if material_row else None,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=_with_document_batch_no(_remark_with_deviation("生产余料入库", payload.deviation_remark), document_batch_no),
|
||||
amount_basis="WEIGHT",
|
||||
)
|
||||
if material_row:
|
||||
material_row.returned_weight_kg = to_decimal(material_row.returned_weight_kg) + surplus_weight
|
||||
db.add(material_row)
|
||||
surplus_lot_id = source_lot.id
|
||||
surplus_txn_id = txn.id
|
||||
|
||||
if scrap_weight > 0 and scrap_warehouse:
|
||||
scrap_lot_no = _build_lot_no(db, "SCRAPI", work_order, material, now)
|
||||
scrap_lot = StockLot(
|
||||
lot_no=scrap_lot_no,
|
||||
parent_lot_id=source_lot.id,
|
||||
lot_role="PRODUCTION_SCRAP",
|
||||
material_sub_batch_no=None,
|
||||
item_id=material.id,
|
||||
warehouse_id=scrap_warehouse.id,
|
||||
location_id=None,
|
||||
source_doc_type="PRODUCTION_SCRAP_IN",
|
||||
source_doc_id=work_order.id,
|
||||
source_line_id=None,
|
||||
source_material_lot_id=source_lot.id,
|
||||
source_material_sub_batch_no=issue.material_sub_batch_no,
|
||||
source_material_summary=f"{material.item_name}:{source_lot.lot_no}",
|
||||
inbound_qty=Decimal("0"),
|
||||
inbound_weight_kg=scrap_weight,
|
||||
remaining_qty=Decimal("0"),
|
||||
remaining_weight_kg=scrap_weight,
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
production_date=now.date(),
|
||||
expire_date=None,
|
||||
quality_status="SCRAP",
|
||||
status="AVAILABLE",
|
||||
remark=_with_document_batch_no(
|
||||
_remark_with_deviation("生产废料入库;来源库存批次已记录", payload.deviation_remark),
|
||||
document_batch_no,
|
||||
),
|
||||
)
|
||||
db.add(scrap_lot)
|
||||
db.flush()
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=material.id,
|
||||
warehouse_id=scrap_warehouse.id,
|
||||
location_id=None,
|
||||
qty_delta=Decimal("0"),
|
||||
weight_delta=scrap_weight,
|
||||
available_qty_delta=Decimal("0"),
|
||||
available_weight_delta=scrap_weight,
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
)
|
||||
txn = create_inventory_txn(
|
||||
db,
|
||||
txn_type="PRODUCTION_SCRAP_IN",
|
||||
item_id=material.id,
|
||||
warehouse_id=scrap_warehouse.id,
|
||||
location_id=None,
|
||||
lot_id=scrap_lot.id,
|
||||
qty_change=Decimal("0"),
|
||||
weight_change=scrap_weight,
|
||||
unit_cost=to_decimal(source_lot.unit_cost),
|
||||
source_doc_type="WORK_ORDER",
|
||||
source_doc_id=work_order.id,
|
||||
source_line_id=None,
|
||||
biz_time=now,
|
||||
operator_user_id=operator_user_id,
|
||||
remark=_with_document_batch_no(_remark_with_deviation("生产废料入库", payload.deviation_remark), document_batch_no),
|
||||
amount_basis="WEIGHT",
|
||||
)
|
||||
scrap_lot_id = scrap_lot.id
|
||||
scrap_txn_id = txn.id
|
||||
|
||||
settled = False
|
||||
if payload.settle_work_order:
|
||||
work_order.status = "SETTLED"
|
||||
work_order.actual_end_time = work_order.actual_end_time or now
|
||||
_append_settle_remark(work_order, payload.deviation_remark, payload.remark)
|
||||
settled = True
|
||||
response_work_order_id = int(work_order.id)
|
||||
response_work_order_no = str(work_order.work_order_no)
|
||||
response_status = str(work_order.status)
|
||||
response_remark = work_order.remark
|
||||
main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
|
||||
db.add(work_order)
|
||||
db.commit()
|
||||
|
||||
archive_status = "未生成"
|
||||
archive_business_id = int(main_txn_id) if main_txn_id else None
|
||||
archive_document_type = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if main_txn_id else None
|
||||
archive_error_message = None
|
||||
if main_txn_id:
|
||||
try:
|
||||
archive_result = generate_document_archive(
|
||||
db,
|
||||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||||
int(main_txn_id),
|
||||
created_by=operator_user_id,
|
||||
)
|
||||
archive_status = archive_result.archive_status
|
||||
archive_error_message = archive_result.archive_error_message
|
||||
except Exception as exc:
|
||||
_ = exc
|
||||
archive_status = "归档失败"
|
||||
archive_error_message = "归档失败:PDF归档服务异常,请稍后重新生成"
|
||||
|
||||
return ProductionWorkOrderInboundRead(
|
||||
work_order_id=response_work_order_id,
|
||||
work_order_no=response_work_order_no,
|
||||
finished_qty=float(_round_decimal(finished_qty)),
|
||||
surplus_weight_kg=float(_round_decimal(surplus_weight)),
|
||||
scrap_weight_kg=float(_round_decimal(scrap_weight)),
|
||||
finished_lot_id=finished_lot_id,
|
||||
surplus_lot_id=surplus_lot_id,
|
||||
scrap_lot_id=scrap_lot_id,
|
||||
finished_txn_id=finished_txn_id,
|
||||
surplus_txn_id=surplus_txn_id,
|
||||
scrap_txn_id=scrap_txn_id,
|
||||
settled=settled,
|
||||
status=response_status,
|
||||
remark=response_remark,
|
||||
archive_status=archive_status,
|
||||
archive_business_id=archive_business_id,
|
||||
archive_document_type=archive_document_type,
|
||||
archive_error_message=archive_error_message,
|
||||
)
|
||||
@@ -0,0 +1,356 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import BomItem, Item
|
||||
from app.models.operations import (
|
||||
CompletionReceipt,
|
||||
CompletionReceiptItem,
|
||||
InventoryTxn,
|
||||
StockLot,
|
||||
WorkOrder,
|
||||
WorkOrderMaterial,
|
||||
WorkOrderMaterialIssue,
|
||||
WorkOrderOperation,
|
||||
)
|
||||
from app.services.operations import to_decimal
|
||||
|
||||
|
||||
INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15")
|
||||
|
||||
|
||||
class WorkOrderLimitSnapshot(BaseModel):
|
||||
work_order_id: int
|
||||
gross_weight_per_piece_kg: Decimal
|
||||
net_weight_per_piece_kg: Decimal
|
||||
issued_weight_kg: Decimal
|
||||
used_material_weight_kg: Decimal
|
||||
returned_material_weight_kg: Decimal
|
||||
final_operation_good_qty: Decimal
|
||||
finished_received_qty: Decimal
|
||||
total_operation_scrap_qty: Decimal
|
||||
scrap_inbound_weight_kg: Decimal
|
||||
standard_scrap_weight_kg: Decimal
|
||||
max_finished_inbound_qty: Decimal
|
||||
max_scrap_inbound_weight_kg: Decimal
|
||||
max_surplus_inbound_weight_kg: Decimal
|
||||
tolerance_finished_inbound_qty: Decimal
|
||||
tolerance_scrap_inbound_weight_kg: Decimal
|
||||
tolerance_surplus_inbound_weight_kg: Decimal
|
||||
|
||||
|
||||
class WorkOrderLedgerMaterialRow(BaseModel):
|
||||
material_item_id: int
|
||||
material_code: str
|
||||
material_name: str
|
||||
source_lot_id: int
|
||||
source_lot_no: str
|
||||
issued_weight_kg: Decimal
|
||||
returned_weight_kg: Decimal = Decimal("0")
|
||||
unit_cost: Decimal = Decimal("0")
|
||||
amount: Decimal = Decimal("0")
|
||||
|
||||
|
||||
class WorkOrderLedgerOperationRow(BaseModel):
|
||||
work_order_operation_id: int
|
||||
seq_no: int
|
||||
operation_name: str
|
||||
report_qty: Decimal
|
||||
good_qty: Decimal
|
||||
scrap_qty: Decimal
|
||||
status: str
|
||||
|
||||
|
||||
class WorkOrderLedgerRow(BaseModel):
|
||||
work_order_id: int
|
||||
work_order_no: str
|
||||
product_item_id: int
|
||||
product_code: str
|
||||
product_name: str
|
||||
planned_qty: Decimal
|
||||
status: str
|
||||
remark: str | None = None
|
||||
material_summary: str
|
||||
source_lot_summary: str
|
||||
issued_weight_kg: Decimal
|
||||
used_material_weight_kg: Decimal
|
||||
returned_material_weight_kg: Decimal
|
||||
finished_received_qty: Decimal
|
||||
max_finished_inbound_qty: Decimal
|
||||
max_scrap_inbound_weight_kg: Decimal
|
||||
max_surplus_inbound_weight_kg: Decimal
|
||||
limits: WorkOrderLimitSnapshot
|
||||
materials: list[WorkOrderLedgerMaterialRow]
|
||||
operations: list[WorkOrderLedgerOperationRow]
|
||||
|
||||
|
||||
def _non_negative(value: Decimal) -> Decimal:
|
||||
return max(value, Decimal("0"))
|
||||
|
||||
|
||||
def _round_decimal(value: Decimal, places: str = "0.001") -> Decimal:
|
||||
return to_decimal(value, places)
|
||||
|
||||
|
||||
def _get_work_order(db: Session, work_order_id: int) -> WorkOrder:
|
||||
work_order = db.get(WorkOrder, work_order_id)
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=404, detail="生产工单不存在")
|
||||
return work_order
|
||||
|
||||
|
||||
def _get_weight_profile(db: Session, work_order: WorkOrder) -> tuple[Decimal, Decimal]:
|
||||
bom_item = db.scalar(
|
||||
select(BomItem)
|
||||
.where(
|
||||
BomItem.bom_id == work_order.bom_id,
|
||||
BomItem.gross_weight_per_piece_kg > 0,
|
||||
)
|
||||
.order_by(BomItem.seq_no, BomItem.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not bom_item:
|
||||
bom_item = db.scalar(
|
||||
select(BomItem)
|
||||
.where(BomItem.bom_id == work_order.bom_id)
|
||||
.order_by(BomItem.seq_no, BomItem.id)
|
||||
.limit(1)
|
||||
)
|
||||
product = db.get(Item, work_order.product_item_id)
|
||||
gross = to_decimal(bom_item.gross_weight_per_piece_kg if bom_item else 0)
|
||||
net = to_decimal(bom_item.net_weight_per_piece_kg if bom_item else 0)
|
||||
if net <= 0 and product:
|
||||
net = to_decimal(product.unit_weight_kg)
|
||||
if gross <= 0:
|
||||
gross = net
|
||||
if gross <= 0:
|
||||
raise HTTPException(status_code=400, detail="产品需用清单缺少产品毛重,无法计算工单入库上限")
|
||||
if net < 0:
|
||||
net = Decimal("0")
|
||||
if gross < net:
|
||||
gross = net
|
||||
return gross, net
|
||||
|
||||
|
||||
def _get_operations(db: Session, work_order_id: int) -> list[WorkOrderOperation]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(WorkOrderOperation)
|
||||
.where(WorkOrderOperation.work_order_id == work_order_id)
|
||||
.order_by(WorkOrderOperation.seq_no, WorkOrderOperation.id)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _get_finished_received_qty(db: Session, work_order_id: int) -> Decimal:
|
||||
value = db.scalar(
|
||||
select(func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0))
|
||||
.join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id)
|
||||
.where(CompletionReceipt.work_order_id == work_order_id)
|
||||
)
|
||||
return to_decimal(value)
|
||||
|
||||
|
||||
def _get_scrap_inbound_weight(db: Session, work_order_id: int) -> Decimal:
|
||||
value = db.scalar(
|
||||
select(func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0)).where(
|
||||
InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN",
|
||||
InventoryTxn.source_doc_type.in_(("WORK_ORDER", "PRODUCTION_SCRAP_IN")),
|
||||
InventoryTxn.source_doc_id == work_order_id,
|
||||
)
|
||||
)
|
||||
return _non_negative(to_decimal(value))
|
||||
|
||||
|
||||
def get_work_order_limit_snapshot(db: Session, work_order_id: int) -> WorkOrderLimitSnapshot:
|
||||
work_order = _get_work_order(db, work_order_id)
|
||||
gross_weight, net_weight = _get_weight_profile(db, work_order)
|
||||
operations = _get_operations(db, work_order_id)
|
||||
issued_weight = to_decimal(
|
||||
db.scalar(
|
||||
select(func.coalesce(func.sum(WorkOrderMaterialIssue.issued_weight_kg), 0)).where(
|
||||
WorkOrderMaterialIssue.work_order_id == work_order_id
|
||||
)
|
||||
)
|
||||
)
|
||||
returned_weight = to_decimal(
|
||||
db.scalar(
|
||||
select(func.coalesce(func.sum(WorkOrderMaterial.returned_weight_kg), 0)).where(
|
||||
WorkOrderMaterial.work_order_id == work_order_id
|
||||
)
|
||||
)
|
||||
)
|
||||
max_report_qty = max((to_decimal(operation.report_qty) for operation in operations), default=Decimal("0"))
|
||||
used_material_weight = _round_decimal(max_report_qty * gross_weight)
|
||||
final_good_qty = to_decimal(operations[-1].good_qty) if operations else Decimal("0")
|
||||
finished_received_qty = _get_finished_received_qty(db, work_order_id)
|
||||
total_scrap_qty = sum((to_decimal(operation.scrap_qty) for operation in operations), Decimal("0"))
|
||||
scrap_inbound_weight = _get_scrap_inbound_weight(db, work_order_id)
|
||||
edge_scrap_weight = _non_negative((max_report_qty - total_scrap_qty) * _non_negative(gross_weight - net_weight))
|
||||
rejected_product_scrap_weight = total_scrap_qty * gross_weight
|
||||
standard_scrap_weight = edge_scrap_weight + rejected_product_scrap_weight
|
||||
max_finished_inbound_qty = _non_negative(final_good_qty - finished_received_qty)
|
||||
max_scrap_inbound_weight = _non_negative(standard_scrap_weight - scrap_inbound_weight)
|
||||
max_surplus_inbound_weight = _non_negative(issued_weight - used_material_weight - returned_weight)
|
||||
tolerance_scrap_inbound_weight = _non_negative(
|
||||
standard_scrap_weight * INBOUND_TOLERANCE_MULTIPLIER - scrap_inbound_weight
|
||||
)
|
||||
tolerance_surplus_inbound_weight = _non_negative(
|
||||
(issued_weight - used_material_weight) * INBOUND_TOLERANCE_MULTIPLIER - returned_weight
|
||||
)
|
||||
|
||||
return WorkOrderLimitSnapshot(
|
||||
work_order_id=work_order_id,
|
||||
gross_weight_per_piece_kg=_round_decimal(gross_weight),
|
||||
net_weight_per_piece_kg=_round_decimal(net_weight),
|
||||
issued_weight_kg=_round_decimal(issued_weight),
|
||||
used_material_weight_kg=_round_decimal(used_material_weight),
|
||||
returned_material_weight_kg=_round_decimal(returned_weight),
|
||||
final_operation_good_qty=_round_decimal(final_good_qty),
|
||||
finished_received_qty=_round_decimal(finished_received_qty),
|
||||
total_operation_scrap_qty=_round_decimal(total_scrap_qty),
|
||||
scrap_inbound_weight_kg=_round_decimal(scrap_inbound_weight),
|
||||
standard_scrap_weight_kg=_round_decimal(standard_scrap_weight),
|
||||
max_finished_inbound_qty=_round_decimal(max_finished_inbound_qty),
|
||||
max_scrap_inbound_weight_kg=_round_decimal(max_scrap_inbound_weight),
|
||||
max_surplus_inbound_weight_kg=_round_decimal(max_surplus_inbound_weight),
|
||||
tolerance_finished_inbound_qty=_round_decimal(max_finished_inbound_qty * INBOUND_TOLERANCE_MULTIPLIER),
|
||||
tolerance_scrap_inbound_weight_kg=_round_decimal(tolerance_scrap_inbound_weight),
|
||||
tolerance_surplus_inbound_weight_kg=_round_decimal(tolerance_surplus_inbound_weight),
|
||||
)
|
||||
|
||||
|
||||
def validate_inbound_with_tolerance(label: str, requested: Decimal, calculated_max: Decimal) -> None:
|
||||
if requested <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"{label}必须大于0")
|
||||
allowed = _round_decimal(_non_negative(calculated_max) * INBOUND_TOLERANCE_MULTIPLIER)
|
||||
if requested > allowed:
|
||||
raise HTTPException(status_code=400, detail=f"{label}超过允许入库上限 {allowed}")
|
||||
|
||||
|
||||
def validate_work_order_source_lot(
|
||||
db: Session,
|
||||
work_order_id: int,
|
||||
source_lot_id: int,
|
||||
item_id: int,
|
||||
) -> WorkOrderMaterial:
|
||||
row = db.execute(
|
||||
select(WorkOrderMaterial)
|
||||
.join(WorkOrderMaterialIssue, WorkOrderMaterialIssue.work_order_material_id == WorkOrderMaterial.id)
|
||||
.where(
|
||||
WorkOrderMaterial.work_order_id == work_order_id,
|
||||
WorkOrderMaterial.material_item_id == item_id,
|
||||
WorkOrderMaterialIssue.source_lot_id == source_lot_id,
|
||||
)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=400, detail="来源库存批次号不属于该生产工单")
|
||||
return row
|
||||
|
||||
|
||||
def get_work_order_ledger_rows(
|
||||
db: Session,
|
||||
limit: int = 200,
|
||||
work_order_id: int | None = None,
|
||||
) -> list[WorkOrderLedgerRow]:
|
||||
stmt = select(WorkOrder).order_by(WorkOrder.id.desc()).limit(limit)
|
||||
if work_order_id:
|
||||
stmt = stmt.where(WorkOrder.id == work_order_id)
|
||||
work_orders = db.scalars(stmt).all()
|
||||
rows: list[WorkOrderLedgerRow] = []
|
||||
|
||||
for work_order in work_orders:
|
||||
product = db.get(Item, work_order.product_item_id)
|
||||
limits = get_work_order_limit_snapshot(db, work_order.id)
|
||||
surplus_return_subquery = (
|
||||
select(
|
||||
InventoryTxn.lot_id.label("source_lot_id"),
|
||||
func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0).label("returned_weight_kg"),
|
||||
)
|
||||
.where(
|
||||
InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN",
|
||||
InventoryTxn.source_doc_type == "WORK_ORDER",
|
||||
InventoryTxn.source_doc_id == work_order.id,
|
||||
)
|
||||
.group_by(InventoryTxn.lot_id)
|
||||
.subquery()
|
||||
)
|
||||
issue_rows = db.execute(
|
||||
select(
|
||||
WorkOrderMaterialIssue.material_item_id,
|
||||
Item.item_code,
|
||||
Item.item_name,
|
||||
WorkOrderMaterialIssue.source_lot_id,
|
||||
StockLot.lot_no,
|
||||
WorkOrderMaterialIssue.issued_weight_kg,
|
||||
func.coalesce(surplus_return_subquery.c.returned_weight_kg, 0).label("returned_weight_kg"),
|
||||
WorkOrderMaterialIssue.unit_cost,
|
||||
WorkOrderMaterialIssue.amount,
|
||||
)
|
||||
.join(Item, Item.id == WorkOrderMaterialIssue.material_item_id)
|
||||
.join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
|
||||
.join(WorkOrderMaterial, WorkOrderMaterial.id == WorkOrderMaterialIssue.work_order_material_id)
|
||||
.outerjoin(surplus_return_subquery, surplus_return_subquery.c.source_lot_id == WorkOrderMaterialIssue.source_lot_id)
|
||||
.where(WorkOrderMaterialIssue.work_order_id == work_order.id)
|
||||
.order_by(WorkOrderMaterialIssue.id)
|
||||
).mappings().all()
|
||||
material_rows = [
|
||||
WorkOrderLedgerMaterialRow(
|
||||
material_item_id=int(issue["material_item_id"]),
|
||||
material_code=issue["item_code"],
|
||||
material_name=issue["item_name"],
|
||||
source_lot_id=int(issue["source_lot_id"]),
|
||||
source_lot_no=issue["lot_no"],
|
||||
issued_weight_kg=_round_decimal(to_decimal(issue["issued_weight_kg"])),
|
||||
returned_weight_kg=_round_decimal(to_decimal(issue["returned_weight_kg"])),
|
||||
unit_cost=_round_decimal(to_decimal(issue["unit_cost"]), "0.0001"),
|
||||
amount=_round_decimal(to_decimal(issue["amount"]), "0.01"),
|
||||
)
|
||||
for issue in issue_rows
|
||||
]
|
||||
operation_rows = [
|
||||
WorkOrderLedgerOperationRow(
|
||||
work_order_operation_id=int(operation.id),
|
||||
seq_no=int(operation.seq_no),
|
||||
operation_name=operation.operation_name,
|
||||
report_qty=_round_decimal(to_decimal(operation.report_qty)),
|
||||
good_qty=_round_decimal(to_decimal(operation.good_qty)),
|
||||
scrap_qty=_round_decimal(to_decimal(operation.scrap_qty)),
|
||||
status=operation.status,
|
||||
)
|
||||
for operation in _get_operations(db, work_order.id)
|
||||
]
|
||||
material_names = sorted({row.material_name for row in material_rows})
|
||||
source_lot_nos = [row.source_lot_no for row in material_rows]
|
||||
rows.append(
|
||||
WorkOrderLedgerRow(
|
||||
work_order_id=int(work_order.id),
|
||||
work_order_no=work_order.work_order_no,
|
||||
product_item_id=int(work_order.product_item_id),
|
||||
product_code=product.item_code if product else "",
|
||||
product_name=product.item_name if product else "",
|
||||
planned_qty=_round_decimal(to_decimal(work_order.planned_qty)),
|
||||
status=work_order.status,
|
||||
remark=work_order.remark,
|
||||
material_summary="、".join(material_names) if material_names else "-",
|
||||
source_lot_summary="、".join(source_lot_nos) if source_lot_nos else "-",
|
||||
issued_weight_kg=limits.issued_weight_kg,
|
||||
used_material_weight_kg=limits.used_material_weight_kg,
|
||||
returned_material_weight_kg=limits.returned_material_weight_kg,
|
||||
finished_received_qty=limits.finished_received_qty,
|
||||
max_finished_inbound_qty=limits.max_finished_inbound_qty,
|
||||
max_scrap_inbound_weight_kg=limits.max_scrap_inbound_weight_kg,
|
||||
max_surplus_inbound_weight_kg=limits.max_surplus_inbound_weight_kg,
|
||||
limits=limits,
|
||||
materials=material_rows,
|
||||
operations=operation_rows,
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import inspect, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.operations import PurchaseOrderItem, PurchaseOrderSalesOrderLink
|
||||
from app.models.planning import MaterialDemand
|
||||
from app.models.sales import SalesOrder, SalesOrderItem
|
||||
|
||||
|
||||
def ensure_purchase_order_sales_link_table(db: Session) -> None:
|
||||
"""Keep the explicit purchase-sales link table available when DB patches lag behind code."""
|
||||
if db.info.get("purchase_order_sales_link_table_ensured"):
|
||||
return
|
||||
bind = db.connection()
|
||||
inspector = inspect(bind)
|
||||
existed = inspector.has_table(PurchaseOrderSalesOrderLink.__tablename__)
|
||||
PurchaseOrderSalesOrderLink.__table__.create(bind=bind, checkfirst=True)
|
||||
if not existed:
|
||||
backfill_purchase_order_sales_links(db)
|
||||
db.info["purchase_order_sales_link_table_ensured"] = True
|
||||
|
||||
|
||||
def backfill_purchase_order_sales_links(db: Session) -> None:
|
||||
existing_pairs = {
|
||||
(int(row.purchase_order_id), int(row.sales_order_id))
|
||||
for row in db.execute(
|
||||
select(PurchaseOrderSalesOrderLink.purchase_order_id, PurchaseOrderSalesOrderLink.sales_order_id)
|
||||
)
|
||||
}
|
||||
inferred_rows = db.execute(
|
||||
select(
|
||||
PurchaseOrderItem.purchase_order_id.label("purchase_order_id"),
|
||||
SalesOrder.id.label("sales_order_id"),
|
||||
)
|
||||
.join(MaterialDemand, MaterialDemand.id == PurchaseOrderItem.source_demand_id)
|
||||
.join(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id)
|
||||
.join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id)
|
||||
.distinct()
|
||||
).all()
|
||||
now = datetime.now()
|
||||
for row in inferred_rows:
|
||||
pair = (int(row.purchase_order_id), int(row.sales_order_id))
|
||||
if pair in existing_pairs:
|
||||
continue
|
||||
db.add(
|
||||
PurchaseOrderSalesOrderLink(
|
||||
purchase_order_id=pair[0],
|
||||
sales_order_id=pair[1],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
existing_pairs.add(pair)
|
||||
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, ROUND_UP
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Select, and_, case, delete, func, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance
|
||||
from app.models.document_archive import DocumentArchive
|
||||
from app.models.operations import Delivery, DeliveryItem, PurchaseOrderItem, WorkOrder
|
||||
from app.models.org import Employee
|
||||
from app.models.planning import MaterialDemand
|
||||
from app.models.sales import Customer, SalesOrder, SalesOrderItem
|
||||
from app.schemas.database import MaterialDemandRead
|
||||
|
||||
|
||||
def build_serial(prefix: str) -> str:
|
||||
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S%f')[-12:]}"
|
||||
|
||||
|
||||
def code_token(value: str | None, fallback: str, max_length: int = 12) -> str:
|
||||
token = "".join(ch for ch in str(value or "").upper() if ch.isalnum())
|
||||
return (token or fallback)[-max_length:]
|
||||
|
||||
|
||||
def next_doc_no(db: Session, model, column_name: str, prefix: str) -> str:
|
||||
column = getattr(model, column_name)
|
||||
count = db.scalar(select(func.count(model.id)).where(column.like(f"{prefix}-%"))) or 0
|
||||
return f"{prefix}-{count + 1:03d}"
|
||||
|
||||
|
||||
def decimalize(value: float | Decimal, places: str = "0.000001") -> Decimal:
|
||||
return Decimal(str(value)).quantize(Decimal(places))
|
||||
|
||||
|
||||
def round_up_by_multiple(value: Decimal, multiple: Decimal) -> Decimal:
|
||||
if multiple <= 0:
|
||||
return decimalize(value)
|
||||
return ((value / multiple).quantize(Decimal("1"), rounding=ROUND_UP) * multiple).quantize(Decimal("0.000001"))
|
||||
|
||||
|
||||
def create_customer_code(db: Session) -> str:
|
||||
date_part = date.today().strftime("%Y%m%d")
|
||||
return next_doc_no(db, Customer, "customer_code", f"CUS-NB-{date_part}")
|
||||
|
||||
|
||||
def build_mrp_demand_no(db: Session, sales_order: SalesOrder, order_item: SalesOrderItem, material_item: Item) -> str:
|
||||
date_part = date.today().strftime("%Y%m%d")
|
||||
order_part = code_token(sales_order.order_no, f"SO{sales_order.id:04d}", 12)
|
||||
material_part = code_token(material_item.item_code or material_item.item_name, f"RM{material_item.id:04d}", 10)
|
||||
prefix = f"MRP-NB-{date_part}-{order_part}-L{int(order_item.line_no or 0):02d}-{material_part}"
|
||||
return next_doc_no(db, MaterialDemand, "demand_no", prefix)
|
||||
|
||||
|
||||
def get_sales_order_query(limit: int = 50) -> Select:
|
||||
sales_employee = aliased(Employee)
|
||||
line_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(SalesOrderItem.id).label("line_count"),
|
||||
)
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
mrp_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(MaterialDemand.id).label("mrp_count"),
|
||||
)
|
||||
.join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id)
|
||||
.where(MaterialDemand.status != "SUPERSEDED")
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
purchase_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(func.distinct(PurchaseOrderItem.purchase_order_id)).label("purchase_order_count"),
|
||||
)
|
||||
.join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id)
|
||||
.join(PurchaseOrderItem, PurchaseOrderItem.source_demand_id == MaterialDemand.id)
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
delivery_count_subquery = (
|
||||
select(
|
||||
Delivery.sales_order_id.label("sales_order_id"),
|
||||
func.count(Delivery.id).label("delivery_count"),
|
||||
)
|
||||
.where(Delivery.sales_order_id.is_not(None))
|
||||
.group_by(Delivery.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
delivery_item_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(DeliveryItem.id).label("delivery_item_count"),
|
||||
)
|
||||
.join(DeliveryItem, DeliveryItem.sales_order_item_id == SalesOrderItem.id)
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
work_order_count_subquery = (
|
||||
select(
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
func.count(WorkOrder.id).label("work_order_count"),
|
||||
)
|
||||
.join(WorkOrder, WorkOrder.source_sales_order_item_id == SalesOrderItem.id)
|
||||
.group_by(SalesOrderItem.sales_order_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_archive_version_subquery = (
|
||||
select(
|
||||
DocumentArchive.business_id.label("business_id"),
|
||||
func.max(DocumentArchive.archive_version).label("archive_version"),
|
||||
)
|
||||
.where(DocumentArchive.document_type == "销售订单", DocumentArchive.file_format == "PDF")
|
||||
.group_by(DocumentArchive.business_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_archive_id_subquery = (
|
||||
select(
|
||||
DocumentArchive.business_id.label("business_id"),
|
||||
func.max(DocumentArchive.id).label("archive_id"),
|
||||
)
|
||||
.join(
|
||||
latest_archive_version_subquery,
|
||||
and_(
|
||||
latest_archive_version_subquery.c.business_id == DocumentArchive.business_id,
|
||||
latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version,
|
||||
),
|
||||
)
|
||||
.where(DocumentArchive.document_type == "销售订单", DocumentArchive.file_format == "PDF")
|
||||
.group_by(DocumentArchive.business_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_archive_subquery = (
|
||||
select(
|
||||
DocumentArchive.business_id.label("business_id"),
|
||||
DocumentArchive.status.label("archive_status"),
|
||||
DocumentArchive.archive_version.label("archive_version"),
|
||||
DocumentArchive.created_at.label("archive_created_at"),
|
||||
DocumentArchive.error_message.label("archive_error_message"),
|
||||
)
|
||||
.join(
|
||||
latest_archive_id_subquery,
|
||||
latest_archive_id_subquery.c.archive_id == DocumentArchive.id,
|
||||
)
|
||||
.where(DocumentArchive.document_type == "销售订单", DocumentArchive.file_format == "PDF")
|
||||
.subquery()
|
||||
)
|
||||
|
||||
return (
|
||||
select(
|
||||
SalesOrder.id.label("order_id"),
|
||||
SalesOrder.order_no.label("order_no"),
|
||||
SalesOrder.customer_id.label("customer_id"),
|
||||
Customer.customer_code.label("customer_code"),
|
||||
Customer.customer_name.label("customer_name"),
|
||||
SalesOrder.sales_employee_id.label("sales_employee_id"),
|
||||
sales_employee.employee_name.label("sales_employee_name"),
|
||||
SalesOrder.order_date.label("order_date"),
|
||||
SalesOrder.promised_date.label("promised_date"),
|
||||
SalesOrder.delivery_address.label("delivery_address"),
|
||||
SalesOrder.total_amount.label("total_amount"),
|
||||
SalesOrder.status.label("status"),
|
||||
func.coalesce(line_count_subquery.c.line_count, 0).label("line_count"),
|
||||
func.coalesce(mrp_count_subquery.c.mrp_count, 0).label("mrp_count"),
|
||||
func.coalesce(purchase_count_subquery.c.purchase_order_count, 0).label("purchase_order_count"),
|
||||
func.coalesce(delivery_count_subquery.c.delivery_count, 0).label("delivery_count"),
|
||||
func.coalesce(delivery_item_count_subquery.c.delivery_item_count, 0).label("delivery_item_count"),
|
||||
func.coalesce(work_order_count_subquery.c.work_order_count, 0).label("work_order_count"),
|
||||
func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"),
|
||||
latest_archive_subquery.c.archive_version.label("archive_version"),
|
||||
latest_archive_subquery.c.archive_created_at.label("archive_created_at"),
|
||||
latest_archive_subquery.c.archive_error_message.label("archive_error_message"),
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
func.coalesce(purchase_count_subquery.c.purchase_order_count, 0) == 0,
|
||||
func.coalesce(delivery_count_subquery.c.delivery_count, 0) == 0,
|
||||
func.coalesce(delivery_item_count_subquery.c.delivery_item_count, 0) == 0,
|
||||
func.coalesce(work_order_count_subquery.c.work_order_count, 0) == 0,
|
||||
),
|
||||
True,
|
||||
),
|
||||
else_=False,
|
||||
).label("can_delete"),
|
||||
)
|
||||
.join(Customer, Customer.id == SalesOrder.customer_id)
|
||||
.outerjoin(sales_employee, sales_employee.id == SalesOrder.sales_employee_id)
|
||||
.outerjoin(line_count_subquery, line_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(mrp_count_subquery, mrp_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(purchase_count_subquery, purchase_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(delivery_count_subquery, delivery_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(delivery_item_count_subquery, delivery_item_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(work_order_count_subquery, work_order_count_subquery.c.sales_order_id == SalesOrder.id)
|
||||
.outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == SalesOrder.id)
|
||||
.order_by(SalesOrder.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
|
||||
def get_sales_order_items_query(limit: int = 50, sales_order_id: int | None = None) -> Select:
|
||||
stmt = (
|
||||
select(
|
||||
SalesOrderItem.id.label("sales_order_item_id"),
|
||||
SalesOrderItem.sales_order_id.label("sales_order_id"),
|
||||
SalesOrder.order_no.label("order_no"),
|
||||
SalesOrderItem.line_no.label("line_no"),
|
||||
SalesOrderItem.product_item_id.label("product_item_id"),
|
||||
Item.item_code.label("product_code"),
|
||||
Item.item_name.label("product_name"),
|
||||
SalesOrderItem.customer_part_no.label("customer_part_no"),
|
||||
SalesOrderItem.order_qty.label("order_qty"),
|
||||
SalesOrderItem.delivered_qty.label("delivered_qty"),
|
||||
SalesOrderItem.unit_price.label("unit_price"),
|
||||
SalesOrderItem.line_amount.label("line_amount"),
|
||||
SalesOrderItem.promised_date.label("promised_date"),
|
||||
SalesOrderItem.status.label("status"),
|
||||
)
|
||||
.join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id)
|
||||
.join(Item, Item.id == SalesOrderItem.product_item_id)
|
||||
.order_by(SalesOrderItem.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if sales_order_id:
|
||||
stmt = stmt.where(SalesOrderItem.sales_order_id == sales_order_id)
|
||||
return stmt
|
||||
|
||||
|
||||
def get_mrp_demands_query(limit: int = 50, sales_order_id: int | None = None) -> Select:
|
||||
product_item = aliased(Item)
|
||||
material_item = aliased(Item)
|
||||
material_ext = aliased(Material)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
MaterialDemand.id.label("demand_id"),
|
||||
MaterialDemand.demand_no.label("demand_no"),
|
||||
SalesOrder.order_no.label("order_no"),
|
||||
MaterialDemand.material_item_id.label("material_item_id"),
|
||||
product_item.item_code.label("product_code"),
|
||||
product_item.item_name.label("product_name"),
|
||||
material_item.item_code.label("material_code"),
|
||||
material_item.item_name.label("material_name"),
|
||||
material_ext.purchase_calc_mode.label("purchase_calc_mode"),
|
||||
MaterialDemand.order_qty.label("order_qty"),
|
||||
MaterialDemand.theoretical_weight_kg.label("theoretical_weight_kg"),
|
||||
MaterialDemand.planned_weight_kg.label("planned_weight_kg"),
|
||||
MaterialDemand.shortage_weight_kg.label("shortage_weight_kg"),
|
||||
MaterialDemand.suggested_purchase_qty.label("suggested_purchase_qty"),
|
||||
MaterialDemand.suggested_purchase_weight_kg.label("suggested_purchase_weight_kg"),
|
||||
MaterialDemand.status.label("status"),
|
||||
MaterialDemand.generated_at.label("generated_at"),
|
||||
)
|
||||
.join(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id)
|
||||
.join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id)
|
||||
.join(product_item, product_item.id == MaterialDemand.product_item_id)
|
||||
.join(material_item, material_item.id == MaterialDemand.material_item_id)
|
||||
.outerjoin(material_ext, material_ext.item_id == MaterialDemand.material_item_id)
|
||||
.where(MaterialDemand.status != "SUPERSEDED")
|
||||
.order_by(MaterialDemand.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if sales_order_id:
|
||||
stmt = stmt.where(SalesOrder.id == sales_order_id)
|
||||
return stmt
|
||||
|
||||
|
||||
def generate_mrp_for_order(db: Session, sales_order_id: int) -> tuple[SalesOrder, list[MaterialDemandRead]]:
|
||||
sales_order = db.get(SalesOrder, sales_order_id)
|
||||
if not sales_order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
|
||||
order_items = db.scalars(
|
||||
select(SalesOrderItem).where(SalesOrderItem.sales_order_id == sales_order_id).order_by(SalesOrderItem.line_no)
|
||||
).all()
|
||||
if not order_items:
|
||||
raise HTTPException(status_code=400, detail="订单没有明细,无法生成MRP")
|
||||
|
||||
item_ids = [item.id for item in order_items]
|
||||
if item_ids:
|
||||
existing_demands = db.scalars(
|
||||
select(MaterialDemand).where(MaterialDemand.sales_order_item_id.in_(item_ids))
|
||||
).all()
|
||||
existing_demand_ids = [demand.id for demand in existing_demands]
|
||||
referenced_demand_ids: set[int] = set()
|
||||
if existing_demand_ids:
|
||||
referenced_demand_ids = set(
|
||||
db.scalars(
|
||||
select(PurchaseOrderItem.source_demand_id).where(
|
||||
PurchaseOrderItem.source_demand_id.in_(existing_demand_ids)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
deletable_demand_ids = [
|
||||
demand_id for demand_id in existing_demand_ids if demand_id not in referenced_demand_ids
|
||||
]
|
||||
if deletable_demand_ids:
|
||||
db.execute(delete(MaterialDemand).where(MaterialDemand.id.in_(deletable_demand_ids)))
|
||||
|
||||
for demand in existing_demands:
|
||||
if demand.id in referenced_demand_ids:
|
||||
demand.status = "SUPERSEDED"
|
||||
demand.remark = "已被重新生成的MRP替代,因已被采购订单引用而保留追溯"
|
||||
db.add(demand)
|
||||
|
||||
db.flush()
|
||||
|
||||
generated_at = datetime.now()
|
||||
created_rows: list[MaterialDemand] = []
|
||||
|
||||
for order_item in order_items:
|
||||
product = db.get(Item, order_item.product_item_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail=f"产品ID {order_item.product_item_id} 不存在")
|
||||
|
||||
bom = db.scalar(
|
||||
select(Bom).where(Bom.product_item_id == product.id, Bom.is_default == 1, Bom.status == "ACTIVE")
|
||||
)
|
||||
if not bom:
|
||||
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 缺少默认BOM")
|
||||
|
||||
bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all()
|
||||
if not bom_items:
|
||||
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 的BOM没有明细")
|
||||
|
||||
order_qty = decimalize(order_item.order_qty)
|
||||
|
||||
for bom_item in bom_items:
|
||||
material_item = db.get(Item, bom_item.material_item_id)
|
||||
if not material_item:
|
||||
raise HTTPException(status_code=400, detail=f"BOM原材料ID {bom_item.material_item_id} 不存在")
|
||||
|
||||
material_ext = db.scalar(select(Material).where(Material.item_id == material_item.id))
|
||||
if not material_ext:
|
||||
raise HTTPException(status_code=400, detail=f"原材料 {material_item.item_name} 缺少采购扩展信息")
|
||||
|
||||
loss_multiplier = Decimal("1") + decimalize(bom_item.loss_rate or 0, "0.0001")
|
||||
material_unit_weight = Decimal(str(material_item.unit_weight_kg or 0))
|
||||
|
||||
available_stock_weight = Decimal(
|
||||
str(
|
||||
db.scalar(
|
||||
select(func.coalesce(func.sum(StockBalance.weight_available_kg), 0)).where(
|
||||
StockBalance.item_id == material_item.id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
)
|
||||
|
||||
gross_weight_per_piece = decimalize(bom_item.gross_weight_per_piece_kg)
|
||||
if gross_weight_per_piece <= 0 and material_unit_weight > 0:
|
||||
gross_weight_per_piece = decimalize(bom_item.qty_per_piece) * material_unit_weight
|
||||
theoretical_weight = order_qty * gross_weight_per_piece
|
||||
planned_weight = theoretical_weight * loss_multiplier
|
||||
|
||||
shortage_weight = max(Decimal("0"), planned_weight - available_stock_weight)
|
||||
|
||||
suggested_purchase_weight = shortage_weight
|
||||
purchase_multiple_weight = Decimal(str(material_ext.purchase_multiple_weight_kg or 0))
|
||||
min_purchase_weight = Decimal(str(material_ext.min_purchase_qty or 0))
|
||||
if suggested_purchase_weight > 0:
|
||||
if purchase_multiple_weight > 0:
|
||||
suggested_purchase_weight = round_up_by_multiple(suggested_purchase_weight, purchase_multiple_weight)
|
||||
elif min_purchase_weight > 0 and suggested_purchase_weight < min_purchase_weight:
|
||||
suggested_purchase_weight = min_purchase_weight.quantize(Decimal("0.000001"))
|
||||
|
||||
demand = MaterialDemand(
|
||||
demand_no=build_mrp_demand_no(db, sales_order, order_item, material_item),
|
||||
sales_order_item_id=order_item.id,
|
||||
product_item_id=product.id,
|
||||
material_item_id=material_item.id,
|
||||
bom_item_id=bom_item.id,
|
||||
order_qty=order_qty,
|
||||
theoretical_weight_kg=theoretical_weight,
|
||||
planned_weight_kg=planned_weight,
|
||||
available_stock_weight_kg=available_stock_weight,
|
||||
shortage_weight_kg=shortage_weight,
|
||||
suggested_purchase_qty=Decimal("0"),
|
||||
suggested_purchase_weight_kg=suggested_purchase_weight,
|
||||
status="NEW",
|
||||
generated_at=generated_at,
|
||||
remark="由订单自动生成",
|
||||
)
|
||||
db.add(demand)
|
||||
created_rows.append(demand)
|
||||
|
||||
db.commit()
|
||||
|
||||
rows = db.execute(get_mrp_demands_query(limit=200, sales_order_id=sales_order_id)).mappings().all()
|
||||
return sales_order, [MaterialDemandRead.model_validate(dict(row)) for row in rows]
|
||||
@@ -0,0 +1,425 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import Item, Warehouse
|
||||
from app.models.operations import InventoryTxn, SpecialWarehouseAdjustment, SpecialWarehouseAdjustmentLine, StockLot
|
||||
from app.schemas.operations import SpecialWarehouseAdjustmentCreate
|
||||
from app.services.operations import (
|
||||
build_inventory_lot_no,
|
||||
clamp_non_negative,
|
||||
create_inventory_txn,
|
||||
get_location_or_default,
|
||||
to_decimal,
|
||||
upsert_stock_balance,
|
||||
)
|
||||
from app.services.sales_planning import next_doc_no
|
||||
from app.services.stocktake import ensure_warehouses_unlocked
|
||||
|
||||
|
||||
SOURCE_DOC_TYPE = "SPECIAL_ADJUSTMENT"
|
||||
LOT_ROLE = "SPECIAL_ADJUSTMENT"
|
||||
|
||||
|
||||
def _strip_text(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _raise_value_from_http(exc: HTTPException) -> None:
|
||||
detail = getattr(exc, "detail", None)
|
||||
raise ValueError(str(detail or exc)) from exc
|
||||
|
||||
|
||||
def _format_decimal(value: Decimal) -> str:
|
||||
normalized = value.quantize(Decimal("0.000001")).normalize()
|
||||
if normalized == normalized.to_integral():
|
||||
return str(normalized.quantize(Decimal("1")))
|
||||
return format(normalized, "f")
|
||||
|
||||
|
||||
def _trim_255(value: str) -> str:
|
||||
return value[:255]
|
||||
|
||||
|
||||
def _trim_to(value: str, limit: int) -> str:
|
||||
if limit <= 0:
|
||||
return ""
|
||||
return value[:limit]
|
||||
|
||||
|
||||
def _warehouse_type_label(warehouse_type: str) -> str:
|
||||
return {
|
||||
"AUX": "辅料",
|
||||
"FINISHED": "成品",
|
||||
"RETURN": "退货",
|
||||
"SEMI": "半成品",
|
||||
"SCRAP": "废品",
|
||||
"RAW": "原材料",
|
||||
}.get(warehouse_type, "库存")
|
||||
|
||||
|
||||
def _build_adjustment_no(db: Session, *, direction: str, adjusted_at: datetime) -> str:
|
||||
direction_label = "入库" if direction == "IN" else "出库"
|
||||
return next_doc_no(
|
||||
db,
|
||||
SpecialWarehouseAdjustment,
|
||||
"adjustment_no",
|
||||
f"特殊调整{adjusted_at.strftime('%Y%m%d')}{direction_label}",
|
||||
)
|
||||
|
||||
|
||||
def _build_special_lot_no(
|
||||
db: Session,
|
||||
*,
|
||||
adjustment_id: int,
|
||||
item_id: int,
|
||||
warehouse_type: str,
|
||||
now: datetime,
|
||||
) -> str:
|
||||
if warehouse_type == "RAW":
|
||||
return build_inventory_lot_no(db, item_id)
|
||||
prefix = f"{_warehouse_type_label(warehouse_type)}特殊{now.strftime('%Y%m%d')}-{adjustment_id}"
|
||||
candidate = next_doc_no(db, StockLot, "lot_no", prefix)
|
||||
sequence = 1
|
||||
while db.scalar(select(StockLot.id).where(StockLot.lot_no == candidate).limit(1)):
|
||||
sequence += 1
|
||||
candidate = f"{prefix}-{sequence:03d}"
|
||||
return candidate
|
||||
|
||||
|
||||
def _normalize_change(
|
||||
*,
|
||||
warehouse_type: str,
|
||||
raw_qty: Decimal,
|
||||
raw_weight: Decimal,
|
||||
) -> tuple[Decimal, Decimal]:
|
||||
qty = to_decimal(raw_qty)
|
||||
weight = to_decimal(raw_weight)
|
||||
if qty < 0 or weight < 0:
|
||||
raise ValueError("特殊调整数量和重量不能小于0")
|
||||
|
||||
if warehouse_type in {"RAW", "SCRAP"}:
|
||||
qty = Decimal("0")
|
||||
if weight <= 0:
|
||||
raise ValueError("特殊调整重量必须大于0")
|
||||
elif warehouse_type == "AUX":
|
||||
weight = Decimal("0")
|
||||
if qty <= 0:
|
||||
raise ValueError("特殊调整数量必须大于0")
|
||||
elif warehouse_type == "SEMI":
|
||||
if qty > 0 and weight > 0:
|
||||
raise ValueError("半成品特殊调整只允许选择数量或重量一种口径")
|
||||
if qty <= 0 and weight <= 0:
|
||||
raise ValueError("特殊调整数量或重量必须大于0")
|
||||
else:
|
||||
if qty <= 0:
|
||||
raise ValueError("特殊调整数量必须大于0")
|
||||
weight = max(weight, Decimal("0"))
|
||||
return to_decimal(qty), to_decimal(weight)
|
||||
|
||||
|
||||
def _assert_out_available(lot: StockLot, *, qty: Decimal, weight: Decimal, warehouse_type: str) -> None:
|
||||
available_qty = clamp_non_negative(to_decimal(lot.remaining_qty) - to_decimal(lot.locked_qty))
|
||||
available_weight = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg))
|
||||
if warehouse_type == "AUX" and qty > available_qty:
|
||||
raise ValueError("特殊出库数量不能超过当前可用数量")
|
||||
if warehouse_type in {"RAW", "SCRAP"} and weight > available_weight:
|
||||
raise ValueError("特殊出库重量不能超过当前可用重量")
|
||||
if warehouse_type not in {"AUX", "RAW", "SCRAP"}:
|
||||
if qty > available_qty:
|
||||
raise ValueError("特殊出库数量不能超过当前可用数量")
|
||||
if weight > available_weight:
|
||||
raise ValueError("特殊出库重量不能超过当前可用重量")
|
||||
|
||||
|
||||
def _build_remark(
|
||||
*,
|
||||
direction: str,
|
||||
reason: str,
|
||||
line_reason: str | None,
|
||||
before_qty: Decimal,
|
||||
after_qty: Decimal,
|
||||
before_weight: Decimal,
|
||||
after_weight: Decimal,
|
||||
) -> str:
|
||||
direction_label = "特殊入库" if direction == "IN" else "特殊出库"
|
||||
metrics = [
|
||||
f"调整前数量:{_format_decimal(before_qty)}",
|
||||
f"调整后数量:{_format_decimal(after_qty)}",
|
||||
f"调整前重量:{_format_decimal(before_weight)}",
|
||||
f"调整后重量:{_format_decimal(after_weight)}",
|
||||
]
|
||||
metric_text = ";".join(metrics)
|
||||
reason_prefix = f"{direction_label}说明:"
|
||||
reason_text = _strip_text(reason)
|
||||
detail = _strip_text(line_reason)
|
||||
|
||||
reserved_without_detail = f"{reason_prefix};{metric_text}"
|
||||
reason_limit = max(0, 255 - len(reserved_without_detail))
|
||||
parts = [f"{reason_prefix}{_trim_to(reason_text, reason_limit)}", metric_text]
|
||||
|
||||
if detail:
|
||||
detail_prefix = "明细说明:"
|
||||
detail_reserved = ";".join(parts) + f";{detail_prefix}"
|
||||
detail_limit = max(0, 255 - len(detail_reserved))
|
||||
trimmed_detail = _trim_to(detail, detail_limit)
|
||||
if trimmed_detail:
|
||||
parts.append(f"{detail_prefix}{trimmed_detail}")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def _create_new_special_lot(
|
||||
db: Session,
|
||||
*,
|
||||
adjustment_id: int,
|
||||
item_id: int,
|
||||
warehouse_id: int,
|
||||
location_id: int | None,
|
||||
warehouse_type: str,
|
||||
qty: Decimal,
|
||||
weight: Decimal,
|
||||
unit_cost: Decimal,
|
||||
reason: str,
|
||||
now: datetime,
|
||||
) -> StockLot:
|
||||
summary = f"特殊入库说明:{reason}"
|
||||
lot = StockLot(
|
||||
lot_no=_build_special_lot_no(
|
||||
db,
|
||||
adjustment_id=adjustment_id,
|
||||
item_id=item_id,
|
||||
warehouse_type=warehouse_type,
|
||||
now=now,
|
||||
),
|
||||
parent_lot_id=None,
|
||||
lot_role=LOT_ROLE,
|
||||
material_sub_batch_no=None,
|
||||
item_id=item_id,
|
||||
warehouse_id=warehouse_id,
|
||||
location_id=location_id,
|
||||
source_doc_type=SOURCE_DOC_TYPE,
|
||||
source_doc_id=adjustment_id,
|
||||
source_line_id=None,
|
||||
source_material_lot_id=None,
|
||||
source_material_sub_batch_no=None,
|
||||
source_material_summary=summary,
|
||||
inbound_qty=qty,
|
||||
inbound_weight_kg=weight,
|
||||
remaining_qty=qty,
|
||||
remaining_weight_kg=weight,
|
||||
locked_qty=to_decimal(0),
|
||||
locked_weight_kg=to_decimal(0),
|
||||
unit_cost=unit_cost,
|
||||
production_date=now.date() if isinstance(now.date(), date) else None,
|
||||
expire_date=None,
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
remark=_trim_255(summary),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(lot)
|
||||
db.flush()
|
||||
return lot
|
||||
|
||||
|
||||
def create_special_inventory_adjustment(
|
||||
db: Session,
|
||||
payload: SpecialWarehouseAdjustmentCreate,
|
||||
user_id: int | None = None,
|
||||
) -> SpecialWarehouseAdjustment:
|
||||
now = datetime.now()
|
||||
direction = str(payload.direction or "").upper()
|
||||
reason = _strip_text(payload.reason)
|
||||
if direction not in {"IN", "OUT"}:
|
||||
raise ValueError("特殊库存调整方向只能是特殊入库或特殊出库")
|
||||
direction_label = "特殊入库" if direction == "IN" else "特殊出库"
|
||||
if len(reason) < 4:
|
||||
raise ValueError(f"{direction_label}必须填写说明")
|
||||
if len(reason) > 1000:
|
||||
raise ValueError(f"{direction_label}说明不能超过1000个字")
|
||||
|
||||
try:
|
||||
warehouse = db.get(Warehouse, payload.warehouse_id)
|
||||
if not warehouse:
|
||||
raise ValueError("仓库不存在")
|
||||
warehouse_type = str(warehouse.warehouse_type or "").upper()
|
||||
ensure_warehouses_unlocked(db, [payload.warehouse_id], direction_label)
|
||||
|
||||
adjustment = SpecialWarehouseAdjustment(
|
||||
adjustment_no=_build_adjustment_no(db, direction=direction, adjusted_at=now),
|
||||
warehouse_id=payload.warehouse_id,
|
||||
warehouse_type=warehouse_type,
|
||||
direction=direction,
|
||||
reason=reason,
|
||||
status="已确认",
|
||||
operator_user_id=user_id,
|
||||
adjusted_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(adjustment)
|
||||
db.flush()
|
||||
|
||||
for index, line_payload in enumerate(payload.lines, start=1):
|
||||
item = db.get(Item, line_payload.item_id)
|
||||
if not item:
|
||||
raise ValueError(f"物料不存在: {line_payload.item_id}")
|
||||
location = get_location_or_default(db, payload.warehouse_id, line_payload.location_id)
|
||||
if location and int(location.warehouse_id) != int(payload.warehouse_id):
|
||||
raise ValueError("选择的库位不属于当前仓库")
|
||||
|
||||
change_qty, change_weight = _normalize_change(
|
||||
warehouse_type=warehouse_type,
|
||||
raw_qty=to_decimal(line_payload.adjust_qty),
|
||||
raw_weight=to_decimal(line_payload.adjust_weight_kg),
|
||||
)
|
||||
unit_cost = to_decimal(line_payload.unit_cost, "0.0001")
|
||||
lot: StockLot | None
|
||||
|
||||
if direction == "OUT":
|
||||
if not line_payload.lot_id:
|
||||
raise ValueError("特殊出库必须选择库存批次")
|
||||
lot = db.get(StockLot, line_payload.lot_id)
|
||||
if not lot or int(lot.item_id) != int(line_payload.item_id) or int(lot.warehouse_id) != int(payload.warehouse_id):
|
||||
raise ValueError("库存批次不存在或不属于当前仓库物料")
|
||||
if location and lot.location_id and int(lot.location_id) != int(location.id):
|
||||
raise ValueError("库存批次库位与选择库位不一致")
|
||||
_assert_out_available(lot, qty=change_qty, weight=change_weight, warehouse_type=warehouse_type)
|
||||
unit_cost = to_decimal(lot.unit_cost, "0.0001") if unit_cost <= 0 else unit_cost
|
||||
before_qty = to_decimal(lot.remaining_qty)
|
||||
before_weight = to_decimal(lot.remaining_weight_kg)
|
||||
after_qty = clamp_non_negative(before_qty - change_qty)
|
||||
after_weight = clamp_non_negative(before_weight - change_weight)
|
||||
qty_delta = -change_qty
|
||||
weight_delta = -change_weight
|
||||
else:
|
||||
if line_payload.lot_id:
|
||||
lot = db.get(StockLot, line_payload.lot_id)
|
||||
if not lot or int(lot.item_id) != int(line_payload.item_id) or int(lot.warehouse_id) != int(payload.warehouse_id):
|
||||
raise ValueError("库存批次不存在或不属于当前仓库物料")
|
||||
if location and lot.location_id and int(lot.location_id) != int(location.id):
|
||||
raise ValueError("库存批次库位与选择库位不一致")
|
||||
unit_cost = to_decimal(lot.unit_cost, "0.0001") if unit_cost <= 0 else unit_cost
|
||||
before_qty = to_decimal(lot.remaining_qty)
|
||||
before_weight = to_decimal(lot.remaining_weight_kg)
|
||||
lot.inbound_qty = to_decimal(lot.inbound_qty) + change_qty
|
||||
lot.inbound_weight_kg = to_decimal(lot.inbound_weight_kg) + change_weight
|
||||
lot.remaining_qty = before_qty + change_qty
|
||||
lot.remaining_weight_kg = before_weight + change_weight
|
||||
lot.status = "AVAILABLE"
|
||||
lot.updated_at = now
|
||||
db.add(lot)
|
||||
else:
|
||||
before_qty = to_decimal(0)
|
||||
before_weight = to_decimal(0)
|
||||
lot = _create_new_special_lot(
|
||||
db,
|
||||
adjustment_id=adjustment.id,
|
||||
item_id=line_payload.item_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
location_id=location.id if location else None,
|
||||
warehouse_type=warehouse_type,
|
||||
qty=change_qty,
|
||||
weight=change_weight,
|
||||
unit_cost=unit_cost,
|
||||
reason=reason,
|
||||
now=now,
|
||||
)
|
||||
after_qty = before_qty + change_qty
|
||||
after_weight = before_weight + change_weight
|
||||
qty_delta = change_qty
|
||||
weight_delta = change_weight
|
||||
|
||||
remark = _build_remark(
|
||||
direction=direction,
|
||||
reason=reason,
|
||||
line_reason=line_payload.line_reason,
|
||||
before_qty=before_qty,
|
||||
after_qty=after_qty,
|
||||
before_weight=before_weight,
|
||||
after_weight=after_weight,
|
||||
)
|
||||
if direction == "OUT":
|
||||
lot.remaining_qty = after_qty
|
||||
lot.remaining_weight_kg = after_weight
|
||||
if after_qty <= 0 and after_weight <= 0:
|
||||
lot.status = "DEPLETED"
|
||||
lot.updated_at = now
|
||||
db.add(lot)
|
||||
|
||||
line = SpecialWarehouseAdjustmentLine(
|
||||
adjustment_id=adjustment.id,
|
||||
line_no=index,
|
||||
item_id=line_payload.item_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
location_id=lot.location_id if lot else (location.id if location else None),
|
||||
lot_id=lot.id,
|
||||
inventory_txn_id=None,
|
||||
before_qty=before_qty,
|
||||
change_qty=qty_delta,
|
||||
after_qty=after_qty,
|
||||
before_weight_kg=before_weight,
|
||||
change_weight_kg=weight_delta,
|
||||
after_weight_kg=after_weight,
|
||||
unit_cost=unit_cost,
|
||||
line_reason=_strip_text(line_payload.line_reason) or reason,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(line)
|
||||
db.flush()
|
||||
if direction == "IN" and lot.source_doc_type == SOURCE_DOC_TYPE and lot.source_line_id is None:
|
||||
lot.source_line_id = line.id
|
||||
db.add(lot)
|
||||
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=line_payload.item_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
qty_delta=qty_delta,
|
||||
weight_delta=weight_delta,
|
||||
available_qty_delta=qty_delta,
|
||||
available_weight_delta=weight_delta,
|
||||
unit_cost=unit_cost,
|
||||
)
|
||||
txn: InventoryTxn = create_inventory_txn(
|
||||
db,
|
||||
txn_type="SPECIAL_IN" if direction == "IN" else "SPECIAL_OUT",
|
||||
item_id=line_payload.item_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
lot_id=lot.id,
|
||||
qty_change=qty_delta,
|
||||
weight_change=weight_delta,
|
||||
unit_cost=unit_cost,
|
||||
source_doc_type=SOURCE_DOC_TYPE,
|
||||
source_doc_id=adjustment.id,
|
||||
source_line_id=line.id,
|
||||
biz_time=now,
|
||||
operator_user_id=user_id,
|
||||
remark=remark,
|
||||
amount_basis="WEIGHT" if abs(weight_delta) > 0 else "QTY",
|
||||
)
|
||||
line.inventory_txn_id = txn.id
|
||||
line.updated_at = now
|
||||
db.add(line)
|
||||
|
||||
db.commit()
|
||||
db.refresh(adjustment)
|
||||
return adjustment
|
||||
except HTTPException as exc:
|
||||
db.rollback()
|
||||
_raise_value_from_http(exc)
|
||||
except ValueError:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,758 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.master_data import Item, Warehouse
|
||||
from app.models.operations import StockLot, Stocktake, StocktakeAdjustment, StocktakeLine, StocktakeWarehouse, WarehouseLocation
|
||||
from app.services.operations import create_inventory_txn, to_decimal, upsert_stock_balance
|
||||
|
||||
ACTIVE_STOCKTAKE_STATUSES = {"LOCKED", "EXPORTED", "IMPORTED"}
|
||||
|
||||
STOCKTAKE_EXCEL_HEADERS = [
|
||||
"盘库单号",
|
||||
"盘库校验码",
|
||||
"仓库ID",
|
||||
"快照行ID",
|
||||
"仓库",
|
||||
"库位",
|
||||
"物料编码",
|
||||
"物料名称",
|
||||
"批次号",
|
||||
"来源库存批次号",
|
||||
"系统数量",
|
||||
"系统重量(kg)",
|
||||
"系统单价",
|
||||
"盘点数量",
|
||||
"盘点重量(kg)",
|
||||
"盘点备注",
|
||||
]
|
||||
STOCKTAKE_QUANTITY_HEADERS = {"系统数量", "盘点数量"}
|
||||
|
||||
EMPTY_STOCKTAKE_MARKER_REMARK = "空库盘点校验行,请保留此行用于校验盘库单号"
|
||||
|
||||
|
||||
def build_stocktake_no(db: Session) -> str:
|
||||
date_part = date.today().strftime("%Y%m%d")
|
||||
prefix = f"PK-{date_part}"
|
||||
count = db.scalar(select(func.count(Stocktake.id)).where(Stocktake.stocktake_no.like(f"{prefix}-%"))) or 0
|
||||
return f"{prefix}-{count + 1:03d}"
|
||||
|
||||
|
||||
def stocktake_warehouse_type_label(value: str | None) -> str:
|
||||
return {
|
||||
"RAW": "原材料库",
|
||||
"SEMI": "半成品库",
|
||||
"FINISHED": "成品库",
|
||||
"AUX": "辅料库",
|
||||
"SCRAP": "废料库",
|
||||
"RETURN": "退货库",
|
||||
}.get(str(value or "").upper(), str(value or "未知仓库"))
|
||||
|
||||
|
||||
def stocktake_validation_token(stocktake_no: str, warehouse_id: int, warehouse_type: str | None) -> str:
|
||||
return f"{stocktake_no}|{int(warehouse_id)}|{str(warehouse_type or '').upper()}"
|
||||
|
||||
|
||||
def stocktake_weight_only(warehouse_type: str | None, item_type: str | None) -> bool:
|
||||
_ = item_type
|
||||
return str(warehouse_type or "").upper() in {"RAW", "SCRAP"}
|
||||
|
||||
|
||||
def stocktake_excel_headers_for_type(warehouse_type: str | None) -> list[str]:
|
||||
if stocktake_weight_only(warehouse_type, None):
|
||||
return [header for header in STOCKTAKE_EXCEL_HEADERS if header not in STOCKTAKE_QUANTITY_HEADERS]
|
||||
return list(STOCKTAKE_EXCEL_HEADERS)
|
||||
|
||||
|
||||
def stocktake_excel_row_for_headers(payload: dict[str, object], headers: list[str]) -> list[object]:
|
||||
return [payload.get(header, "") for header in headers]
|
||||
|
||||
|
||||
def get_locked_warehouse_ids(db: Session, warehouse_ids: list[int] | set[int]) -> set[int]:
|
||||
ids = {int(item) for item in warehouse_ids if item}
|
||||
if not ids:
|
||||
return set()
|
||||
rows = db.scalars(
|
||||
select(StocktakeWarehouse.warehouse_id)
|
||||
.join(Stocktake, Stocktake.id == StocktakeWarehouse.stocktake_id)
|
||||
.where(
|
||||
StocktakeWarehouse.warehouse_id.in_(ids),
|
||||
StocktakeWarehouse.status == "LOCKED",
|
||||
Stocktake.status.in_(ACTIVE_STOCKTAKE_STATUSES),
|
||||
)
|
||||
).all()
|
||||
return {int(row) for row in rows}
|
||||
|
||||
|
||||
def ensure_warehouses_unlocked(db: Session, warehouse_ids: list[int] | set[int], action_label: str) -> None:
|
||||
locked_ids = get_locked_warehouse_ids(db, warehouse_ids)
|
||||
if not locked_ids:
|
||||
return
|
||||
rows = db.scalars(select(Warehouse).where(Warehouse.id.in_(locked_ids)).order_by(Warehouse.id.asc())).all()
|
||||
labels = "、".join(row.warehouse_name for row in rows)
|
||||
raise HTTPException(status_code=423, detail=f"{labels}正在盘库,暂不能执行{action_label}")
|
||||
|
||||
|
||||
def decimal_value(value: object, places: str = "0.000001") -> Decimal:
|
||||
return Decimal(str(value or 0)).quantize(Decimal(places))
|
||||
|
||||
|
||||
def current_time() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def start_stocktake(db: Session, *, warehouse_ids: list[int], user_id: int | None, remark: str | None = None) -> Stocktake:
|
||||
ids = [int(item) for item in warehouse_ids if item]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="请选择需要盘库的仓库")
|
||||
if len(set(ids)) != 1:
|
||||
raise HTTPException(status_code=400, detail="一次只能盘一个仓库,请先在仓库栏选中单个仓库")
|
||||
ensure_warehouses_unlocked(db, ids, "开始盘库")
|
||||
warehouses = db.scalars(select(Warehouse).where(Warehouse.id.in_(ids)).order_by(Warehouse.id.asc())).all()
|
||||
if len(warehouses) != len(set(ids)):
|
||||
raise HTTPException(status_code=404, detail="存在无效仓库,无法开始盘库")
|
||||
|
||||
now = current_time()
|
||||
stocktake = Stocktake(
|
||||
stocktake_no=build_stocktake_no(db),
|
||||
status="LOCKED",
|
||||
started_by_user_id=user_id,
|
||||
started_at=now,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(stocktake)
|
||||
db.flush()
|
||||
|
||||
warehouses_by_id = {warehouse.id: warehouse for warehouse in warehouses}
|
||||
stocktake_warehouses_by_id: dict[int, StocktakeWarehouse] = {}
|
||||
for warehouse in warehouses:
|
||||
row = StocktakeWarehouse(
|
||||
stocktake_id=stocktake.id,
|
||||
warehouse_id=warehouse.id,
|
||||
warehouse_type=str(warehouse.warehouse_type or "").upper(),
|
||||
warehouse_name=warehouse.warehouse_name,
|
||||
status="LOCKED",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
stocktake_warehouses_by_id[warehouse.id] = row
|
||||
|
||||
lot_rows = db.execute(
|
||||
select(StockLot, Item)
|
||||
.join(Item, Item.id == StockLot.item_id)
|
||||
.where(
|
||||
StockLot.warehouse_id.in_(ids),
|
||||
StockLot.status == "AVAILABLE",
|
||||
StockLot.remaining_weight_kg > 0,
|
||||
)
|
||||
.order_by(StockLot.warehouse_id.asc(), Item.item_code.asc(), StockLot.lot_no.asc(), StockLot.id.asc())
|
||||
).all()
|
||||
|
||||
line_no = 1
|
||||
for lot, item in lot_rows:
|
||||
warehouse = warehouses_by_id.get(lot.warehouse_id)
|
||||
if not warehouse:
|
||||
continue
|
||||
weight_only = stocktake_weight_only(warehouse.warehouse_type, item.item_type)
|
||||
location_name = None
|
||||
if lot.location_id:
|
||||
location_name = db.scalar(select(WarehouseLocation.location_name).where(WarehouseLocation.id == lot.location_id))
|
||||
db.add(
|
||||
StocktakeLine(
|
||||
stocktake_id=stocktake.id,
|
||||
stocktake_warehouse_id=stocktake_warehouses_by_id[warehouse.id].id,
|
||||
line_no=line_no,
|
||||
warehouse_id=warehouse.id,
|
||||
warehouse_name=warehouse.warehouse_name,
|
||||
warehouse_type=str(warehouse.warehouse_type or "").upper(),
|
||||
location_id=lot.location_id,
|
||||
location_name=location_name,
|
||||
item_id=item.id,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_name,
|
||||
item_type=item.item_type,
|
||||
lot_id=lot.id,
|
||||
lot_no=lot.lot_no,
|
||||
source_material_sub_batch_no=lot.source_material_sub_batch_no or lot.material_sub_batch_no,
|
||||
snapshot_qty=Decimal("0") if weight_only else to_decimal(lot.remaining_qty),
|
||||
snapshot_weight_kg=to_decimal(lot.remaining_weight_kg),
|
||||
snapshot_unit_cost=to_decimal(lot.unit_cost, "0.0001"),
|
||||
counted_qty=None,
|
||||
counted_weight_kg=None,
|
||||
diff_qty=Decimal("0"),
|
||||
diff_weight_kg=Decimal("0"),
|
||||
diff_amount=Decimal("0"),
|
||||
diff_type="MATCH",
|
||||
row_status="SNAPSHOT",
|
||||
remark=None,
|
||||
)
|
||||
)
|
||||
line_no += 1
|
||||
|
||||
db.commit()
|
||||
db.refresh(stocktake)
|
||||
return stocktake
|
||||
|
||||
|
||||
def _stocktake_lines(db: Session, stocktake_id: int) -> list[StocktakeLine]:
|
||||
return db.scalars(
|
||||
select(StocktakeLine)
|
||||
.where(StocktakeLine.stocktake_id == stocktake_id)
|
||||
.order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc())
|
||||
).all()
|
||||
|
||||
|
||||
def _single_stocktake_warehouse(db: Session, stocktake_id: int) -> StocktakeWarehouse:
|
||||
rows = db.scalars(
|
||||
select(StocktakeWarehouse)
|
||||
.where(StocktakeWarehouse.stocktake_id == stocktake_id)
|
||||
.order_by(StocktakeWarehouse.id.asc())
|
||||
).all()
|
||||
if len(rows) != 1:
|
||||
raise HTTPException(status_code=400, detail="当前盘库单不是单仓库盘库,无法继续处理")
|
||||
return rows[0]
|
||||
|
||||
|
||||
def build_stocktake_workbook(db: Session, stocktake_id: int) -> Workbook:
|
||||
stocktake = db.get(Stocktake, stocktake_id)
|
||||
if not stocktake:
|
||||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||||
lines = _stocktake_lines(db, stocktake_id)
|
||||
stocktake_warehouses = db.scalars(
|
||||
select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id).order_by(StocktakeWarehouse.id.asc())
|
||||
).all()
|
||||
workbook = Workbook()
|
||||
default_sheet = workbook.active
|
||||
workbook.remove(default_sheet)
|
||||
grouped: dict[int, list[StocktakeLine]] = {}
|
||||
for line in lines:
|
||||
grouped.setdefault(int(line.warehouse_id), []).append(line)
|
||||
for warehouse in stocktake_warehouses:
|
||||
grouped.setdefault(int(warehouse.warehouse_id), [])
|
||||
warehouses_by_id = {int(warehouse.warehouse_id): warehouse for warehouse in stocktake_warehouses}
|
||||
for warehouse_id, sheet_lines in grouped.items():
|
||||
warehouse = warehouses_by_id.get(int(warehouse_id))
|
||||
if not warehouse:
|
||||
continue
|
||||
sheet_name = stocktake_warehouse_type_label(warehouse.warehouse_type)
|
||||
worksheet = workbook.create_sheet(title=sheet_name[:31])
|
||||
worksheet.freeze_panes = "A2"
|
||||
headers = stocktake_excel_headers_for_type(warehouse.warehouse_type)
|
||||
worksheet.append(headers)
|
||||
if not sheet_lines:
|
||||
token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type)
|
||||
worksheet.append(
|
||||
stocktake_excel_row_for_headers(
|
||||
{
|
||||
"盘库单号": stocktake.stocktake_no,
|
||||
"盘库校验码": token,
|
||||
"仓库ID": warehouse.warehouse_id,
|
||||
"快照行ID": "",
|
||||
"仓库": sheet_name,
|
||||
"库位": "",
|
||||
"物料编码": "",
|
||||
"物料名称": "",
|
||||
"批次号": "",
|
||||
"来源库存批次号": "",
|
||||
"系统数量": 0,
|
||||
"系统重量(kg)": 0,
|
||||
"系统单价": 0,
|
||||
"盘点数量": 0,
|
||||
"盘点重量(kg)": 0,
|
||||
"盘点备注": EMPTY_STOCKTAKE_MARKER_REMARK,
|
||||
},
|
||||
headers,
|
||||
)
|
||||
)
|
||||
for line in sheet_lines:
|
||||
worksheet.append(
|
||||
stocktake_excel_row_for_headers(
|
||||
{
|
||||
"盘库单号": stocktake.stocktake_no,
|
||||
"盘库校验码": stocktake_validation_token(stocktake.stocktake_no, line.warehouse_id, line.warehouse_type),
|
||||
"仓库ID": line.warehouse_id,
|
||||
"快照行ID": line.id,
|
||||
"仓库": line.warehouse_name,
|
||||
"库位": line.location_name or "",
|
||||
"物料编码": line.item_code,
|
||||
"物料名称": line.item_name,
|
||||
"批次号": line.lot_no or "",
|
||||
"来源库存批次号": line.source_material_sub_batch_no or "",
|
||||
"系统数量": float(line.snapshot_qty or 0),
|
||||
"系统重量(kg)": float(line.snapshot_weight_kg or 0),
|
||||
"系统单价": float(line.snapshot_unit_cost or 0),
|
||||
"盘点数量": float(line.snapshot_qty or 0),
|
||||
"盘点重量(kg)": float(line.snapshot_weight_kg or 0),
|
||||
"盘点备注": "",
|
||||
},
|
||||
headers,
|
||||
)
|
||||
)
|
||||
widths_by_header = {
|
||||
"盘库单号": 18,
|
||||
"盘库校验码": 30,
|
||||
"仓库ID": 10,
|
||||
"快照行ID": 12,
|
||||
"仓库": 14,
|
||||
"库位": 14,
|
||||
"物料编码": 18,
|
||||
"物料名称": 28,
|
||||
"批次号": 22,
|
||||
"来源库存批次号": 24,
|
||||
"系统数量": 14,
|
||||
"系统重量(kg)": 16,
|
||||
"系统单价": 12,
|
||||
"盘点数量": 14,
|
||||
"盘点重量(kg)": 16,
|
||||
"盘点备注": 24,
|
||||
}
|
||||
for index, header in enumerate(headers, start=1):
|
||||
width = widths_by_header.get(header, 14)
|
||||
worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width
|
||||
stocktake.exported_at = current_time()
|
||||
stocktake.status = "EXPORTED"
|
||||
db.add(stocktake)
|
||||
db.commit()
|
||||
return workbook
|
||||
|
||||
|
||||
def _clean_text(value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _read_workbook_rows(content: bytes, warehouse_type: str | None = None) -> list[dict[str, object]]:
|
||||
try:
|
||||
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||||
except Exception as exc:
|
||||
raise ValueError("Excel 文件读取失败,请确认上传的是盘库清单 xlsx 文件") from exc
|
||||
rows: list[dict[str, object]] = []
|
||||
for worksheet in workbook.worksheets:
|
||||
values = list(worksheet.iter_rows(values_only=True))
|
||||
if not values:
|
||||
continue
|
||||
headers = [_clean_text(item) for item in values[0]]
|
||||
required_headers = stocktake_excel_headers_for_type(warehouse_type)
|
||||
missing = [header for header in required_headers if header not in headers]
|
||||
if missing:
|
||||
raise ValueError(f"{worksheet.title} 缺少必要列:{'、'.join(missing)}")
|
||||
for row in values[1:]:
|
||||
if not any(_clean_text(item) for item in row):
|
||||
continue
|
||||
payload = {headers[index]: value for index, value in enumerate(row) if index < len(headers)}
|
||||
payload["_sheet_name"] = worksheet.title
|
||||
rows.append(payload)
|
||||
return rows
|
||||
|
||||
|
||||
def _is_empty_stocktake_marker_row(row: dict[str, object]) -> bool:
|
||||
remark = _clean_text(row.get("盘点备注"))
|
||||
if "空库" not in remark:
|
||||
return False
|
||||
if _clean_text(row.get("快照行ID")):
|
||||
return False
|
||||
if any(_clean_text(row.get(label)) for label in ["物料编码", "物料名称", "批次号", "来源库存批次号"]):
|
||||
return False
|
||||
return all(
|
||||
decimal_value(row.get(label)) == 0
|
||||
for label in ["系统数量", "系统重量(kg)", "系统单价", "盘点数量", "盘点重量(kg)"]
|
||||
)
|
||||
|
||||
|
||||
def _validate_stocktake_row_marker(row: dict[str, object], stocktake: Stocktake, warehouse: StocktakeWarehouse) -> None:
|
||||
row_stocktake_no = _clean_text(row.get("盘库单号"))
|
||||
if row_stocktake_no != stocktake.stocktake_no:
|
||||
raise ValueError(f"存在不属于当前盘库单的行:{row_stocktake_no or '空盘库单号'}")
|
||||
|
||||
row_warehouse_id = _clean_text(row.get("仓库ID"))
|
||||
if row_warehouse_id and int(float(row_warehouse_id)) != int(warehouse.warehouse_id):
|
||||
raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致")
|
||||
|
||||
row_warehouse_name = _clean_text(row.get("仓库"))
|
||||
if row_warehouse_name and row_warehouse_name != warehouse.warehouse_name:
|
||||
raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致")
|
||||
|
||||
expected_token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type)
|
||||
row_token = _clean_text(row.get("盘库校验码"))
|
||||
if row_token != expected_token:
|
||||
raise ValueError("盘库校验码不匹配,请导入本次盘库导出的仓库清单")
|
||||
|
||||
|
||||
def _reset_stocktake_preview_for_reimport(db: Session, stocktake_id: int, warehouse_id: int) -> dict[int, StocktakeLine]:
|
||||
lines = db.scalars(
|
||||
select(StocktakeLine)
|
||||
.where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == warehouse_id)
|
||||
.order_by(StocktakeLine.line_no.asc())
|
||||
).all()
|
||||
existing_lines: dict[int, StocktakeLine] = {}
|
||||
for line in lines:
|
||||
if line.lot_id is None and line.diff_type == "NEW_LOT":
|
||||
db.delete(line)
|
||||
continue
|
||||
line.counted_qty = None
|
||||
line.counted_weight_kg = None
|
||||
line.diff_qty = Decimal("0")
|
||||
line.diff_weight_kg = Decimal("0")
|
||||
line.diff_amount = Decimal("0")
|
||||
line.diff_type = "MATCH"
|
||||
line.row_status = "SNAPSHOT"
|
||||
line.error_message = None
|
||||
line.remark = None
|
||||
db.add(line)
|
||||
existing_lines[line.id] = line
|
||||
db.flush()
|
||||
return existing_lines
|
||||
|
||||
|
||||
def _line_diff_type(qty_diff: Decimal, weight_diff: Decimal, *, is_new: bool = False, is_missing: bool = False) -> str:
|
||||
if is_new:
|
||||
return "NEW_LOT"
|
||||
if is_missing:
|
||||
return "MISSING"
|
||||
basis = weight_diff if weight_diff != 0 else qty_diff
|
||||
if basis > 0:
|
||||
return "GAIN"
|
||||
if basis < 0:
|
||||
return "LOSS"
|
||||
return "MATCH"
|
||||
|
||||
|
||||
def _stocktake_summary(lines: list[StocktakeLine]) -> dict[str, int | float]:
|
||||
summary = {
|
||||
"match_count": 0,
|
||||
"gain_count": 0,
|
||||
"loss_count": 0,
|
||||
"new_lot_count": 0,
|
||||
"missing_count": 0,
|
||||
"error_count": 0,
|
||||
"diff_amount": 0.0,
|
||||
}
|
||||
for line in lines:
|
||||
key = {
|
||||
"MATCH": "match_count",
|
||||
"GAIN": "gain_count",
|
||||
"LOSS": "loss_count",
|
||||
"NEW_LOT": "new_lot_count",
|
||||
"MISSING": "missing_count",
|
||||
}.get(line.diff_type)
|
||||
if key:
|
||||
summary[key] += 1
|
||||
if line.row_status == "ERROR":
|
||||
summary["error_count"] += 1
|
||||
summary["diff_amount"] += float(line.diff_amount or 0)
|
||||
return summary
|
||||
|
||||
|
||||
def import_stocktake_workbook(db: Session, stocktake_id: int, content: bytes, user_id: int | None) -> dict[str, object]:
|
||||
stocktake = db.get(Stocktake, stocktake_id)
|
||||
if not stocktake:
|
||||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||||
if stocktake.status not in {"LOCKED", "EXPORTED", "IMPORTED"}:
|
||||
raise HTTPException(status_code=400, detail="当前盘库单状态不允许导入")
|
||||
stocktake_warehouse = _single_stocktake_warehouse(db, stocktake_id)
|
||||
|
||||
rows = _read_workbook_rows(content, stocktake_warehouse.warehouse_type)
|
||||
|
||||
existing_lines = _reset_stocktake_preview_for_reimport(db, stocktake_id, stocktake_warehouse.warehouse_id)
|
||||
seen_line_ids: set[int] = set()
|
||||
next_line_no = max((line.line_no for line in existing_lines.values()), default=0) + 1
|
||||
|
||||
for row in rows:
|
||||
_validate_stocktake_row_marker(row, stocktake, stocktake_warehouse)
|
||||
if _is_empty_stocktake_marker_row(row):
|
||||
continue
|
||||
line_id_text = _clean_text(row.get("快照行ID"))
|
||||
counted_qty = decimal_value(row.get("盘点数量"))
|
||||
counted_weight = decimal_value(row.get("盘点重量(kg)"))
|
||||
remark = _clean_text(row.get("盘点备注")) or None
|
||||
if line_id_text:
|
||||
line_id = int(float(line_id_text))
|
||||
line = existing_lines.get(line_id)
|
||||
if not line:
|
||||
raise ValueError(f"快照行ID {line_id} 不存在于当前盘库单")
|
||||
line.error_message = None
|
||||
if line_id in seen_line_ids:
|
||||
line.error_message = "导入清单中该快照行重复"
|
||||
seen_line_ids.add(line_id)
|
||||
snapshot_qty = decimal_value(line.snapshot_qty)
|
||||
snapshot_weight = decimal_value(line.snapshot_weight_kg)
|
||||
weight_only = stocktake_weight_only(line.warehouse_type, line.item_type)
|
||||
qty_diff = Decimal("0") if weight_only else counted_qty - snapshot_qty
|
||||
weight_diff = counted_weight - snapshot_weight
|
||||
line.counted_qty = Decimal("0") if weight_only else counted_qty
|
||||
line.counted_weight_kg = counted_weight
|
||||
line.diff_qty = qty_diff
|
||||
line.diff_weight_kg = weight_diff
|
||||
line.diff_amount = (weight_diff * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01"))
|
||||
line.diff_type = _line_diff_type(qty_diff, weight_diff)
|
||||
line.row_status = "ERROR" if line.error_message else "READY"
|
||||
line.remark = remark
|
||||
db.add(line)
|
||||
continue
|
||||
|
||||
item_code = _clean_text(row.get("物料编码"))
|
||||
item = db.scalar(select(Item).where(Item.item_code == item_code).limit(1))
|
||||
warehouse = db.get(Warehouse, stocktake_warehouse.warehouse_id)
|
||||
if not item or not warehouse:
|
||||
raise ValueError(f"新增批次行物料或仓库不存在:{stocktake_warehouse.warehouse_name} / {item_code}")
|
||||
unit_cost = decimal_value(row.get("系统单价"), "0.0001")
|
||||
weight_only = stocktake_weight_only(warehouse.warehouse_type, item.item_type)
|
||||
db.add(
|
||||
StocktakeLine(
|
||||
stocktake_id=stocktake.id,
|
||||
stocktake_warehouse_id=stocktake_warehouse.id,
|
||||
line_no=next_line_no,
|
||||
warehouse_id=warehouse.id,
|
||||
warehouse_name=warehouse.warehouse_name,
|
||||
warehouse_type=str(warehouse.warehouse_type or "").upper(),
|
||||
location_id=None,
|
||||
location_name=_clean_text(row.get("库位")) or None,
|
||||
item_id=item.id,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_name,
|
||||
item_type=item.item_type,
|
||||
lot_id=None,
|
||||
lot_no=_clean_text(row.get("批次号")) or None,
|
||||
source_material_sub_batch_no=_clean_text(row.get("来源库存批次号")) or None,
|
||||
snapshot_qty=Decimal("0"),
|
||||
snapshot_weight_kg=Decimal("0"),
|
||||
snapshot_unit_cost=unit_cost,
|
||||
counted_qty=Decimal("0") if weight_only else counted_qty,
|
||||
counted_weight_kg=counted_weight,
|
||||
diff_qty=Decimal("0") if weight_only else counted_qty,
|
||||
diff_weight_kg=counted_weight,
|
||||
diff_amount=(counted_weight * unit_cost).quantize(Decimal("0.01")),
|
||||
diff_type="NEW_LOT",
|
||||
row_status="READY",
|
||||
remark=remark,
|
||||
)
|
||||
)
|
||||
next_line_no += 1
|
||||
|
||||
for line_id, line in existing_lines.items():
|
||||
if line_id in seen_line_ids:
|
||||
continue
|
||||
line.counted_qty = Decimal("0")
|
||||
line.counted_weight_kg = Decimal("0")
|
||||
line.diff_qty = -decimal_value(line.snapshot_qty)
|
||||
line.diff_weight_kg = -decimal_value(line.snapshot_weight_kg)
|
||||
line.diff_amount = (decimal_value(line.diff_weight_kg) * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01"))
|
||||
line.diff_type = "MISSING"
|
||||
line.row_status = "READY"
|
||||
line.error_message = None
|
||||
line.remark = "导入清单缺失,按盘点为0处理"
|
||||
db.add(line)
|
||||
|
||||
stocktake.status = "IMPORTED"
|
||||
stocktake.imported_by_user_id = user_id
|
||||
stocktake.imported_at = current_time()
|
||||
db.add(stocktake)
|
||||
db.commit()
|
||||
|
||||
lines = db.scalars(
|
||||
select(StocktakeLine)
|
||||
.where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == stocktake_warehouse.warehouse_id)
|
||||
.order_by(StocktakeLine.line_no.asc())
|
||||
).all()
|
||||
return {"stocktake": stocktake, "lines": lines, "summary": _stocktake_summary(lines)}
|
||||
|
||||
|
||||
def _stocktake_txn_type(diff_type: str) -> str:
|
||||
return {
|
||||
"GAIN": "STOCKTAKE_GAIN",
|
||||
"LOSS": "STOCKTAKE_LOSS",
|
||||
"MISSING": "STOCKTAKE_LOSS",
|
||||
"NEW_LOT": "STOCKTAKE_NEW_LOT",
|
||||
}.get(diff_type, "STOCKTAKE_GAIN")
|
||||
|
||||
|
||||
def confirm_stocktake(
|
||||
db: Session,
|
||||
stocktake_id: int,
|
||||
*,
|
||||
user_id: int | None,
|
||||
confirm_text: str | None = None,
|
||||
remark: str | None = None,
|
||||
) -> Stocktake:
|
||||
_ = confirm_text
|
||||
stocktake = db.get(Stocktake, stocktake_id)
|
||||
if not stocktake:
|
||||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||||
lines = db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id).order_by(StocktakeLine.line_no.asc())).all()
|
||||
now = current_time()
|
||||
if stocktake.status != "IMPORTED":
|
||||
if stocktake.status in {"LOCKED", "EXPORTED"} and not lines:
|
||||
stocktake.status = "IMPORTED"
|
||||
stocktake.imported_by_user_id = user_id
|
||||
stocktake.imported_at = now
|
||||
db.add(stocktake)
|
||||
db.flush()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="请先导入盘点清单并确认差异后再过账")
|
||||
error_lines = [line for line in lines if line.row_status == "ERROR"]
|
||||
if error_lines:
|
||||
raise HTTPException(status_code=400, detail=f"存在 {len(error_lines)} 行异常,不能确认盘库")
|
||||
|
||||
for line in lines:
|
||||
if line.diff_type == "MATCH":
|
||||
continue
|
||||
weight_only = stocktake_weight_only(line.warehouse_type, line.item_type)
|
||||
unit_cost = decimal_value(line.snapshot_unit_cost, "0.0001")
|
||||
qty_change = decimal_value(line.diff_qty)
|
||||
weight_change = decimal_value(line.diff_weight_kg)
|
||||
if line.diff_type == "NEW_LOT":
|
||||
counted_qty = Decimal("0") if weight_only else max(Decimal("0"), decimal_value(line.counted_qty))
|
||||
counted_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg))
|
||||
lot = StockLot(
|
||||
lot_no=line.lot_no or f"{stocktake.stocktake_no}-NEW-{line.line_no:04d}",
|
||||
lot_role="STOCKTAKE_NEW",
|
||||
item_id=line.item_id,
|
||||
warehouse_id=line.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
source_doc_type="STOCKTAKE",
|
||||
source_doc_id=stocktake.id,
|
||||
source_line_id=line.id,
|
||||
source_material_sub_batch_no=line.source_material_sub_batch_no,
|
||||
source_material_summary="盘库新增批次",
|
||||
inbound_qty=counted_qty,
|
||||
inbound_weight_kg=counted_weight,
|
||||
remaining_qty=counted_qty,
|
||||
remaining_weight_kg=counted_weight,
|
||||
locked_qty=Decimal("0"),
|
||||
locked_weight_kg=Decimal("0"),
|
||||
unit_cost=unit_cost,
|
||||
production_date=now.date(),
|
||||
quality_status="PASS",
|
||||
status="AVAILABLE",
|
||||
remark=line.remark or "盘库新增",
|
||||
)
|
||||
db.add(lot)
|
||||
db.flush()
|
||||
lot_id = lot.id
|
||||
before_qty = Decimal("0")
|
||||
before_weight = Decimal("0")
|
||||
after_qty = counted_qty
|
||||
after_weight = counted_weight
|
||||
qty_change = after_qty
|
||||
weight_change = after_weight
|
||||
else:
|
||||
lot = db.get(StockLot, line.lot_id)
|
||||
if not lot:
|
||||
raise HTTPException(status_code=400, detail=f"盘库行 {line.line_no} 对应批次不存在")
|
||||
before_qty = Decimal("0") if weight_only else decimal_value(lot.remaining_qty)
|
||||
before_weight = decimal_value(lot.remaining_weight_kg)
|
||||
after_qty = Decimal("0") if weight_only else max(Decimal("0"), decimal_value(line.counted_qty))
|
||||
after_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg))
|
||||
qty_change = after_qty - before_qty
|
||||
weight_change = after_weight - before_weight
|
||||
lot.remaining_qty = after_qty
|
||||
lot.remaining_weight_kg = after_weight
|
||||
lot.status = "AVAILABLE" if after_qty > 0 or after_weight > 0 else "DEPLETED"
|
||||
db.add(lot)
|
||||
lot_id = lot.id
|
||||
|
||||
upsert_stock_balance(
|
||||
db,
|
||||
item_id=line.item_id,
|
||||
warehouse_id=line.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
qty_delta=qty_change,
|
||||
weight_delta=weight_change,
|
||||
available_qty_delta=qty_change,
|
||||
available_weight_delta=weight_change,
|
||||
unit_cost=unit_cost,
|
||||
)
|
||||
txn = create_inventory_txn(
|
||||
db,
|
||||
txn_type=_stocktake_txn_type(line.diff_type),
|
||||
item_id=line.item_id,
|
||||
warehouse_id=line.warehouse_id,
|
||||
location_id=line.location_id,
|
||||
lot_id=lot_id,
|
||||
qty_change=qty_change,
|
||||
weight_change=weight_change,
|
||||
unit_cost=unit_cost,
|
||||
source_doc_type="STOCKTAKE",
|
||||
source_doc_id=stocktake.id,
|
||||
source_line_id=line.id,
|
||||
biz_time=now,
|
||||
operator_user_id=user_id,
|
||||
remark=line.remark or remark or "盘库调整",
|
||||
amount_basis="WEIGHT",
|
||||
)
|
||||
db.flush()
|
||||
db.add(
|
||||
StocktakeAdjustment(
|
||||
stocktake_id=stocktake.id,
|
||||
stocktake_line_id=line.id,
|
||||
warehouse_id=line.warehouse_id,
|
||||
item_id=line.item_id,
|
||||
lot_id=line.lot_id,
|
||||
adjustment_lot_id=lot_id,
|
||||
inventory_txn_id=txn.id,
|
||||
adjustment_type=line.diff_type,
|
||||
before_qty=before_qty,
|
||||
after_qty=after_qty,
|
||||
before_weight_kg=before_weight,
|
||||
after_weight_kg=after_weight,
|
||||
qty_change=qty_change,
|
||||
weight_change_kg=weight_change,
|
||||
unit_cost=unit_cost,
|
||||
amount=(abs(weight_change) * unit_cost).quantize(Decimal("0.01")),
|
||||
)
|
||||
)
|
||||
|
||||
for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all():
|
||||
warehouse.status = "UNLOCKED"
|
||||
db.add(warehouse)
|
||||
stocktake.status = "CONFIRMED"
|
||||
stocktake.confirmed_by_user_id = user_id
|
||||
stocktake.confirmed_at = now
|
||||
if remark:
|
||||
stocktake.remark = remark
|
||||
db.add(stocktake)
|
||||
db.commit()
|
||||
db.refresh(stocktake)
|
||||
return stocktake
|
||||
|
||||
|
||||
def cancel_stocktake(db: Session, stocktake_id: int, *, user_id: int | None, remark: str | None = None) -> Stocktake:
|
||||
stocktake = db.get(Stocktake, stocktake_id)
|
||||
if not stocktake:
|
||||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||||
if stocktake.status not in ACTIVE_STOCKTAKE_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="当前盘库单不能取消")
|
||||
now = current_time()
|
||||
for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all():
|
||||
warehouse.status = "UNLOCKED"
|
||||
db.add(warehouse)
|
||||
stocktake.status = "CANCELED"
|
||||
stocktake.canceled_by_user_id = user_id
|
||||
stocktake.canceled_at = now
|
||||
stocktake.remark = remark or stocktake.remark
|
||||
db.add(stocktake)
|
||||
db.commit()
|
||||
db.refresh(stocktake)
|
||||
return stocktake
|
||||
|
||||
|
||||
def stocktake_lines_for_read(db: Session, stocktake_id: int) -> list[StocktakeLine]:
|
||||
return db.scalars(
|
||||
select(StocktakeLine)
|
||||
.where(StocktakeLine.stocktake_id == stocktake_id)
|
||||
.order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc())
|
||||
).all()
|
||||
|
||||
|
||||
def stocktake_warehouses_for_read(db: Session, stocktake_id: int) -> list[StocktakeWarehouse]:
|
||||
return db.scalars(
|
||||
select(StocktakeWarehouse)
|
||||
.where(StocktakeWarehouse.stocktake_id == stocktake_id)
|
||||
.order_by(StocktakeWarehouse.id.asc())
|
||||
).all()
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.org import SystemConfig
|
||||
|
||||
RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE = "RAW_MATERIAL_LOT_PREFIX"
|
||||
RAW_MATERIAL_LOT_PREFIX_DEFAULT = "YL"
|
||||
SMART_OPERATION_REPORT_CONFIG_CODE = "SMART_OPERATION_REPORT_ENABLED"
|
||||
MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE = "MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT"
|
||||
MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME = "小程序报工上次同步成功时间"
|
||||
MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
def normalize_raw_material_lot_prefix(value: str | None) -> str:
|
||||
normalized = re.sub(r"[^A-Z0-9]", "", str(value or "").upper())
|
||||
return normalized or RAW_MATERIAL_LOT_PREFIX_DEFAULT
|
||||
|
||||
|
||||
def normalize_enabled_config_value(value: object) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "开启" if value else "关闭"
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in {"开启", "启用", "true", "1", "yes", "on", "enabled"}:
|
||||
return "开启"
|
||||
if normalized in {"关闭", "停用", "false", "0", "no", "off", "disabled"}:
|
||||
return "关闭"
|
||||
return "开启"
|
||||
|
||||
|
||||
def get_system_config_value(db: Session, config_code: str, default: str = "") -> str:
|
||||
value = db.scalar(
|
||||
select(SystemConfig.config_value).where(
|
||||
SystemConfig.config_code == config_code,
|
||||
SystemConfig.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
return str(value) if value is not None else default
|
||||
|
||||
|
||||
def get_raw_material_lot_prefix(db: Session) -> str:
|
||||
value = get_system_config_value(
|
||||
db,
|
||||
RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
|
||||
RAW_MATERIAL_LOT_PREFIX_DEFAULT,
|
||||
)
|
||||
return normalize_raw_material_lot_prefix(value)
|
||||
|
||||
|
||||
def get_smart_operation_report_enabled(db: Session) -> bool:
|
||||
value = get_system_config_value(db, SMART_OPERATION_REPORT_CONFIG_CODE, "开启")
|
||||
return normalize_enabled_config_value(value) == "开启"
|
||||
|
||||
|
||||
def format_miniapp_operation_report_last_sync(value: datetime) -> str:
|
||||
return value.strftime(MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT)
|
||||
|
||||
|
||||
def parse_miniapp_operation_report_last_sync(value: str | None) -> datetime | None:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return datetime.strptime(normalized, MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT)
|
||||
|
||||
|
||||
def get_miniapp_operation_report_last_sync(db: Session) -> datetime | None:
|
||||
value = get_system_config_value(db, MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, "")
|
||||
return parse_miniapp_operation_report_last_sync(value)
|
||||
|
||||
|
||||
def upsert_miniapp_operation_report_last_sync(
|
||||
db: Session,
|
||||
timestamp: datetime,
|
||||
*,
|
||||
updated_by: int | None = None,
|
||||
) -> SystemConfig:
|
||||
return upsert_system_config(
|
||||
db,
|
||||
config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
|
||||
config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME,
|
||||
config_value=format_miniapp_operation_report_last_sync(timestamp),
|
||||
remark="工序报工小程序同步 ERP 的上次成功截止时间",
|
||||
updated_by=updated_by,
|
||||
now=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def ensure_miniapp_operation_report_last_sync_config(
|
||||
db: Session,
|
||||
*,
|
||||
updated_by: int | None = None,
|
||||
) -> SystemConfig:
|
||||
row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE))
|
||||
if row:
|
||||
return row
|
||||
timestamp = datetime.now()
|
||||
row = SystemConfig(
|
||||
config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
|
||||
config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME,
|
||||
config_value="",
|
||||
remark="工序报工小程序同步 ERP 的上次成功截止时间",
|
||||
status="ACTIVE",
|
||||
created_by=updated_by,
|
||||
updated_by=updated_by,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def lock_miniapp_operation_report_last_sync(db: Session) -> SystemConfig | None:
|
||||
ensure_miniapp_operation_report_last_sync_config(db)
|
||||
return db.scalar(
|
||||
select(SystemConfig)
|
||||
.where(
|
||||
SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
|
||||
SystemConfig.status == "ACTIVE",
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def upsert_smart_operation_report_config(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool,
|
||||
updated_by: int | None = None,
|
||||
remark: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> SystemConfig:
|
||||
return upsert_system_config(
|
||||
db,
|
||||
config_code=SMART_OPERATION_REPORT_CONFIG_CODE,
|
||||
config_name="对接智能报工小程序",
|
||||
config_value=normalize_enabled_config_value(enabled),
|
||||
remark=remark or "开启后显示工序报工并按小程序报工数据计算生产进度;关闭后按ERP入库数据反推生产进度",
|
||||
updated_by=updated_by,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def lock_raw_material_lot_prefix_config(db: Session) -> None:
|
||||
"""Serialize lot-number generation on databases that support row locks."""
|
||||
db.scalar(
|
||||
select(SystemConfig.id)
|
||||
.where(
|
||||
SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
|
||||
SystemConfig.status == "ACTIVE",
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def upsert_system_config(
|
||||
db: Session,
|
||||
*,
|
||||
config_code: str,
|
||||
config_name: str,
|
||||
config_value: str,
|
||||
remark: str | None = None,
|
||||
updated_by: int | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> SystemConfig:
|
||||
timestamp = now or datetime.now()
|
||||
saved_value = (
|
||||
normalize_raw_material_lot_prefix(config_value)
|
||||
if config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE
|
||||
else str(config_value or "").strip()
|
||||
)
|
||||
row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == config_code))
|
||||
if not row:
|
||||
row = SystemConfig(
|
||||
config_code=config_code,
|
||||
created_by=updated_by,
|
||||
created_at=timestamp,
|
||||
)
|
||||
|
||||
row.config_name = config_name
|
||||
row.config_value = saved_value
|
||||
row.remark = remark
|
||||
row.status = "ACTIVE"
|
||||
row.updated_by = updated_by
|
||||
row.updated_at = timestamp
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
# Use a Core update so caller-supplied business timestamps are not overwritten by
|
||||
# the global ORM before_update audit hook.
|
||||
db.execute(
|
||||
update(SystemConfig)
|
||||
.where(SystemConfig.id == row.id)
|
||||
.values(
|
||||
config_name=config_name,
|
||||
config_value=saved_value,
|
||||
remark=remark,
|
||||
status="ACTIVE",
|
||||
updated_by=updated_by,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -0,0 +1,905 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, delete, exists, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.org import (
|
||||
Department,
|
||||
Employee,
|
||||
OrgEmployeeBinding,
|
||||
OrgManagerBinding,
|
||||
Permission,
|
||||
Role,
|
||||
RolePermission,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
from app.schemas.system_permissions import (
|
||||
EmployeeOption,
|
||||
MenuPermissionNode,
|
||||
OrgEmployeeNode,
|
||||
OrgEmployeeBind,
|
||||
OrgNodeCreate,
|
||||
OrgNodeRead,
|
||||
OrgNodeUpdate,
|
||||
PermissionRead,
|
||||
RoleCreate,
|
||||
RoleRead,
|
||||
RoleUpdate,
|
||||
SystemUserCreate,
|
||||
SystemUserRead,
|
||||
)
|
||||
from app.services.auth import hash_password
|
||||
|
||||
|
||||
MENU_PERMISSION_TREE = [
|
||||
("MENU_DASHBOARD", "经营驾驶舱", []),
|
||||
(
|
||||
"MENU_MASTER_DATA",
|
||||
"基础资料",
|
||||
[
|
||||
("MENU_SUPPLIER_MANAGEMENT", "供应商名录", []),
|
||||
("MENU_MATERIAL_MANAGEMENT", "原材料名录", []),
|
||||
("MENU_SALES_PLANNING", "客户名录/销售订单", []),
|
||||
("MENU_PRODUCT_MANAGEMENT", "产品管理", []),
|
||||
("MENU_BOM_MANAGEMENT", "BOM管理", []),
|
||||
("MENU_ROUTE_MANAGEMENT", "工艺路线管理", []),
|
||||
("MENU_EMPLOYEE_MANAGEMENT", "人员管理", []),
|
||||
],
|
||||
),
|
||||
(
|
||||
"MENU_PROCUREMENT",
|
||||
"采购与库存",
|
||||
[
|
||||
("MENU_PURCHASE_ORDER", "采购订单", []),
|
||||
("MENU_PURCHASE_RECEIPT", "到货入库", []),
|
||||
("MENU_QUALITY_INSPECTION", "质量检验", []),
|
||||
("MENU_INVENTORY_LEDGER", "嘉恒仓库", []),
|
||||
],
|
||||
),
|
||||
(
|
||||
"MENU_PRODUCTION",
|
||||
"生产执行",
|
||||
[
|
||||
("MENU_WORK_ORDER", "工单台账", []),
|
||||
("MENU_OPERATION_REPORT", "工序报工", []),
|
||||
("MENU_EQUIPMENT_MANAGEMENT", "设备管理", []),
|
||||
],
|
||||
),
|
||||
("MENU_DELIVERY_MANAGEMENT", "发货概览", []),
|
||||
("MENU_FINANCE_REPORT", "财务分析", []),
|
||||
(
|
||||
"MENU_SYSTEM_MANAGEMENT",
|
||||
"系统管理",
|
||||
[
|
||||
("MENU_SYSTEM_PERMISSION", "系统权限管理", []),
|
||||
("MENU_SYSTEM_EXTENSION", "系统拓展", []),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
ORG_ROOT_CODE = "ORG_ROOT"
|
||||
ORG_ROOT_NAME = "总公司"
|
||||
VALID_CHILD_NODE_TYPES = {
|
||||
"COMPANY": {"BRANCH": "总公司下只能新增分公司"},
|
||||
"BRANCH": {"DEPARTMENT": "分公司下只能新增部门"},
|
||||
"DEPARTMENT": {"GROUP": "部门下只能新增小组"},
|
||||
"GROUP": {},
|
||||
}
|
||||
ORG_NODE_TYPE_LABELS = {
|
||||
"COMPANY": "总公司",
|
||||
"BRANCH": "分公司",
|
||||
"DEPARTMENT": "部门",
|
||||
"GROUP": "小组",
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _normalize_phone(value: str | None) -> str:
|
||||
return re.sub(r"\s+", "", str(value or "").strip())
|
||||
|
||||
|
||||
def _employee_name_sort_key(employee: Employee) -> tuple[str, int]:
|
||||
surname_order = {
|
||||
"王": "wang",
|
||||
"李": "li",
|
||||
"张": "zhang",
|
||||
"刘": "liu",
|
||||
"陈": "chen",
|
||||
"杨": "yang",
|
||||
"赵": "zhao",
|
||||
"黄": "huang",
|
||||
"周": "zhou",
|
||||
"吴": "wu",
|
||||
"徐": "xu",
|
||||
"孙": "sun",
|
||||
"胡": "hu",
|
||||
"朱": "zhu",
|
||||
"高": "gao",
|
||||
"林": "lin",
|
||||
"何": "he",
|
||||
"郭": "guo",
|
||||
"马": "ma",
|
||||
"罗": "luo",
|
||||
}
|
||||
name = employee.employee_name or ""
|
||||
return (f"{surname_order.get(name[:1], name[:1])}{name[1:]}", employee.id)
|
||||
|
||||
|
||||
def _menu_node(raw: tuple[str, str, list]) -> MenuPermissionNode:
|
||||
code, name, children = raw
|
||||
return MenuPermissionNode(
|
||||
permission_code=code,
|
||||
permission_name=name,
|
||||
children=[_menu_node(child) for child in children],
|
||||
)
|
||||
|
||||
|
||||
def list_menu_permission_tree() -> list[MenuPermissionNode]:
|
||||
return [_menu_node(item) for item in MENU_PERMISSION_TREE]
|
||||
|
||||
|
||||
def list_permissions(db: Session) -> list[PermissionRead]:
|
||||
rows = db.scalars(select(Permission).order_by(Permission.module_code, Permission.id)).all()
|
||||
return [
|
||||
PermissionRead(
|
||||
permission_id=row.id,
|
||||
permission_code=row.permission_code,
|
||||
permission_name=row.permission_name,
|
||||
module_code=row.module_code,
|
||||
action_code=row.action_code,
|
||||
status=row.status,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _employee_role_map(db: Session, employee_ids: list[int]) -> tuple[dict[int, str], dict[int, list[str]]]:
|
||||
if not employee_ids:
|
||||
return {}, {}
|
||||
rows = db.execute(
|
||||
select(User.employee_id, User.username, Role.role_name)
|
||||
.select_from(User)
|
||||
.outerjoin(UserRole, UserRole.user_id == User.id)
|
||||
.outerjoin(Role, Role.id == UserRole.role_id)
|
||||
.where(User.employee_id.in_(employee_ids))
|
||||
.order_by(User.employee_id, Role.id)
|
||||
).all()
|
||||
username_map: dict[int, str] = {}
|
||||
role_map: dict[int, list[str]] = {}
|
||||
for employee_id, username, role_name in rows:
|
||||
if employee_id is None:
|
||||
continue
|
||||
username_map.setdefault(employee_id, username)
|
||||
if role_name:
|
||||
role_map.setdefault(employee_id, [])
|
||||
if role_name not in role_map[employee_id]:
|
||||
role_map[employee_id].append(role_name)
|
||||
return username_map, role_map
|
||||
|
||||
|
||||
def _ensure_org_root(db: Session) -> Department:
|
||||
root = db.scalar(select(Department).where(Department.dept_code == ORG_ROOT_CODE))
|
||||
if root:
|
||||
return root
|
||||
|
||||
now = _now()
|
||||
root = Department(
|
||||
dept_code=ORG_ROOT_CODE,
|
||||
dept_name=ORG_ROOT_NAME,
|
||||
parent_id=None,
|
||||
org_node_type="COMPANY",
|
||||
dept_type="ADMIN",
|
||||
manager_name=None,
|
||||
manager_employee_id=None,
|
||||
status="ACTIVE",
|
||||
sort_no=0,
|
||||
remark="系统权限管理组织脑图根节点",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(root)
|
||||
db.commit()
|
||||
db.refresh(root)
|
||||
return root
|
||||
|
||||
|
||||
def _managed_department_ids(root: Department, departments: list[Department]) -> set[int]:
|
||||
children_by_parent: dict[int | None, list[Department]] = {}
|
||||
for dept in departments:
|
||||
children_by_parent.setdefault(dept.parent_id, []).append(dept)
|
||||
|
||||
result: set[int] = set()
|
||||
|
||||
def walk(node_id: int) -> None:
|
||||
result.add(node_id)
|
||||
for child in children_by_parent.get(node_id, []):
|
||||
walk(child.id)
|
||||
|
||||
walk(root.id)
|
||||
return result
|
||||
|
||||
|
||||
def _bound_employees_by_dept(db: Session, dept_ids: set[int]) -> dict[int, list[OrgEmployeeNode]]:
|
||||
if not dept_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(OrgEmployeeBinding, Employee)
|
||||
.join(Employee, Employee.id == OrgEmployeeBinding.employee_id)
|
||||
.where(
|
||||
OrgEmployeeBinding.dept_id.in_(dept_ids),
|
||||
OrgEmployeeBinding.status == "ACTIVE",
|
||||
Employee.status == "ACTIVE",
|
||||
)
|
||||
.order_by(OrgEmployeeBinding.dept_id, OrgEmployeeBinding.sort_no, OrgEmployeeBinding.id)
|
||||
).all()
|
||||
employees = [employee for _, employee in rows]
|
||||
username_map, role_map = _employee_role_map(db, [employee.id for employee in employees])
|
||||
employees_by_dept: dict[int, list[OrgEmployeeNode]] = {}
|
||||
for binding, employee in rows:
|
||||
employees_by_dept.setdefault(binding.dept_id, []).append(
|
||||
OrgEmployeeNode(
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.employee_name,
|
||||
mobile=employee.mobile,
|
||||
job_title=employee.job_title,
|
||||
username=username_map.get(employee.id),
|
||||
role_names=role_map.get(employee.id, []),
|
||||
status=employee.status,
|
||||
)
|
||||
)
|
||||
return employees_by_dept
|
||||
|
||||
|
||||
def _active_employees_for_ids(db: Session, employee_ids: list[int]) -> list[Employee]:
|
||||
unique_ids = list(dict.fromkeys(employee_ids))
|
||||
if not unique_ids:
|
||||
return []
|
||||
rows = db.scalars(select(Employee).where(Employee.id.in_(unique_ids), Employee.status == "ACTIVE")).all()
|
||||
employee_map = {employee.id: employee for employee in rows}
|
||||
missing = [employee_id for employee_id in unique_ids if employee_id not in employee_map]
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"主管不存在或已停用:{missing}")
|
||||
return [employee_map[employee_id] for employee_id in unique_ids]
|
||||
|
||||
|
||||
def _manager_payload_ids(payload: OrgNodeCreate | OrgNodeUpdate) -> list[int]:
|
||||
if payload.manager_employee_ids:
|
||||
return list(dict.fromkeys(payload.manager_employee_ids))
|
||||
return [payload.manager_employee_id] if payload.manager_employee_id else []
|
||||
|
||||
|
||||
def _replace_org_managers(db: Session, dept: Department, manager_employee_ids: list[int]) -> None:
|
||||
managers = _active_employees_for_ids(db, manager_employee_ids)
|
||||
db.execute(delete(OrgManagerBinding).where(OrgManagerBinding.dept_id == dept.id))
|
||||
now = _now()
|
||||
for index, manager in enumerate(managers, start=1):
|
||||
db.add(
|
||||
OrgManagerBinding(
|
||||
dept_id=dept.id,
|
||||
employee_id=manager.id,
|
||||
sort_no=index,
|
||||
status="ACTIVE",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
dept.manager_employee_id = managers[0].id if managers else None
|
||||
dept.manager_name = "、".join(manager.employee_name for manager in managers) if managers else None
|
||||
|
||||
|
||||
def _manager_bindings_by_dept(
|
||||
db: Session,
|
||||
dept_ids: set[int],
|
||||
departments: list[Department],
|
||||
) -> dict[int, tuple[list[int], list[str]]]:
|
||||
if not dept_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(OrgManagerBinding, Employee)
|
||||
.join(Employee, Employee.id == OrgManagerBinding.employee_id)
|
||||
.where(
|
||||
OrgManagerBinding.dept_id.in_(dept_ids),
|
||||
OrgManagerBinding.status == "ACTIVE",
|
||||
Employee.status == "ACTIVE",
|
||||
)
|
||||
.order_by(OrgManagerBinding.dept_id, OrgManagerBinding.sort_no, OrgManagerBinding.id)
|
||||
).all()
|
||||
result: dict[int, tuple[list[int], list[str]]] = {}
|
||||
for binding, employee in rows:
|
||||
manager_ids, manager_names = result.setdefault(binding.dept_id, ([], []))
|
||||
manager_ids.append(employee.id)
|
||||
manager_names.append(employee.employee_name)
|
||||
|
||||
legacy_ids = [dept.manager_employee_id for dept in departments if dept.id in dept_ids and dept.manager_employee_id]
|
||||
legacy_employees = (
|
||||
{employee.id: employee for employee in db.scalars(select(Employee).where(Employee.id.in_(legacy_ids))).all()}
|
||||
if legacy_ids
|
||||
else {}
|
||||
)
|
||||
for dept in departments:
|
||||
if dept.id not in dept_ids or dept.id in result:
|
||||
continue
|
||||
if dept.manager_employee_id:
|
||||
employee = legacy_employees.get(dept.manager_employee_id)
|
||||
result[dept.id] = (
|
||||
[dept.manager_employee_id],
|
||||
[employee.employee_name if employee else dept.manager_name or "未命名主管"],
|
||||
)
|
||||
elif dept.manager_name:
|
||||
result[dept.id] = ([], [dept.manager_name])
|
||||
return result
|
||||
|
||||
|
||||
def build_org_tree(db: Session) -> list[OrgNodeRead]:
|
||||
root = _ensure_org_root(db)
|
||||
departments = db.scalars(
|
||||
select(Department).order_by(Department.parent_id.is_not(None), Department.sort_no, Department.id)
|
||||
).all()
|
||||
managed_ids = _managed_department_ids(root, departments)
|
||||
departments = [dept for dept in departments if dept.id in managed_ids]
|
||||
employees_by_dept = _bound_employees_by_dept(db, managed_ids)
|
||||
managers_by_dept = _manager_bindings_by_dept(db, managed_ids, departments)
|
||||
|
||||
nodes: dict[int, OrgNodeRead] = {
|
||||
dept.id: OrgNodeRead(
|
||||
node_id=dept.id,
|
||||
node_type=getattr(dept, "org_node_type", None) or "DEPARTMENT",
|
||||
node_label=dept.dept_name,
|
||||
parent_id=dept.parent_id,
|
||||
dept_code=dept.dept_code,
|
||||
dept_type=dept.dept_type,
|
||||
manager_employee_id=(managers_by_dept.get(dept.id, ([], []))[0] or [None])[0],
|
||||
manager_name="、".join(managers_by_dept.get(dept.id, ([], []))[1]) or None,
|
||||
manager_employee_ids=managers_by_dept.get(dept.id, ([], []))[0],
|
||||
manager_names=managers_by_dept.get(dept.id, ([], []))[1],
|
||||
status=dept.status,
|
||||
sort_no=dept.sort_no,
|
||||
remark=dept.remark,
|
||||
employees=employees_by_dept.get(dept.id, []),
|
||||
children=[],
|
||||
)
|
||||
for dept in departments
|
||||
}
|
||||
roots: list[OrgNodeRead] = []
|
||||
for dept in departments:
|
||||
node = nodes[dept.id]
|
||||
if dept.parent_id and dept.parent_id in nodes:
|
||||
nodes[dept.parent_id].children.append(node)
|
||||
elif dept.id == root.id:
|
||||
roots.append(node)
|
||||
return roots
|
||||
|
||||
|
||||
def _flatten_org_nodes(nodes: list[OrgNodeRead]) -> list[OrgNodeRead]:
|
||||
result: list[OrgNodeRead] = []
|
||||
for node in nodes:
|
||||
result.append(node)
|
||||
result.extend(_flatten_org_nodes(node.children))
|
||||
return result
|
||||
|
||||
|
||||
def _next_department_code(db: Session, node_type: str) -> str:
|
||||
prefix = {"COMPANY": "COMP", "BRANCH": "BR", "DEPARTMENT": "DEPT", "GROUP": "GRP"}.get(node_type, "DEPT")
|
||||
count = db.scalar(select(func.count(Department.id)).where(Department.dept_code.like(f"{prefix}-%"))) or 0
|
||||
return f"{prefix}-{int(count) + 1:03d}"
|
||||
|
||||
|
||||
def _manager_name(db: Session, employee_id: int | None) -> str | None:
|
||||
if not employee_id:
|
||||
return None
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail="负责人不存在或已停用")
|
||||
return employee.employee_name
|
||||
|
||||
|
||||
def create_org_node(db: Session, payload: OrgNodeCreate) -> OrgNodeRead:
|
||||
if not payload.parent_id:
|
||||
if payload.node_type != "COMPANY":
|
||||
raise HTTPException(status_code=400, detail="请选择上级组织节点")
|
||||
parent = db.get(Department, payload.parent_id) if payload.parent_id else None
|
||||
if payload.parent_id and not parent:
|
||||
raise HTTPException(status_code=404, detail="上级组织节点不存在")
|
||||
if parent:
|
||||
parent_type = getattr(parent, "org_node_type", None) or "DEPARTMENT"
|
||||
allowed = VALID_CHILD_NODE_TYPES.get(parent_type, {})
|
||||
if payload.node_type not in allowed:
|
||||
message = next(iter(allowed.values()), f"{ORG_NODE_TYPE_LABELS.get(parent_type, '当前节点')}下不能新增组织节点")
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
dept_code = payload.dept_code or _next_department_code(db, payload.node_type)
|
||||
if db.scalar(select(Department).where(Department.dept_code == dept_code)):
|
||||
raise HTTPException(status_code=400, detail="组织编码已存在")
|
||||
now = _now()
|
||||
row = Department(
|
||||
dept_code=dept_code,
|
||||
dept_name=payload.node_label,
|
||||
parent_id=payload.parent_id,
|
||||
org_node_type=payload.node_type,
|
||||
dept_type=payload.dept_type,
|
||||
manager_name=None,
|
||||
manager_employee_id=None,
|
||||
status=payload.status,
|
||||
sort_no=payload.sort_no,
|
||||
remark=payload.remark,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
_replace_org_managers(db, row, _manager_payload_ids(payload))
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id)
|
||||
|
||||
|
||||
def update_org_node(db: Session, node_id: int, payload: OrgNodeUpdate) -> OrgNodeRead:
|
||||
row = db.get(Department, node_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="组织节点不存在")
|
||||
row.dept_name = payload.node_label
|
||||
row.dept_type = payload.dept_type
|
||||
_replace_org_managers(db, row, _manager_payload_ids(payload))
|
||||
row.sort_no = payload.sort_no
|
||||
row.remark = payload.remark
|
||||
row.status = payload.status
|
||||
row.updated_at = _now()
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id)
|
||||
|
||||
|
||||
def delete_org_node(db: Session, node_id: int) -> None:
|
||||
row = db.get(Department, node_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="组织节点不存在")
|
||||
child_count = db.scalar(select(func.count(Department.id)).where(Department.parent_id == node_id)) or 0
|
||||
if int(child_count) > 0:
|
||||
raise HTTPException(status_code=400, detail="该节点存在下级节点,请先从底层开始删除")
|
||||
employee_count = (
|
||||
db.scalar(
|
||||
select(func.count(OrgEmployeeBinding.id)).where(
|
||||
OrgEmployeeBinding.dept_id == node_id,
|
||||
OrgEmployeeBinding.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if int(employee_count) > 0:
|
||||
raise HTTPException(status_code=400, detail="该节点存在人员节点,请先移除人员")
|
||||
if row.dept_code == ORG_ROOT_CODE:
|
||||
raise HTTPException(status_code=400, detail="总公司根节点不能删除")
|
||||
db.execute(delete(OrgManagerBinding).where(OrgManagerBinding.dept_id == node_id))
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
|
||||
|
||||
def bind_org_employee(db: Session, node_id: int, payload: OrgEmployeeBind) -> OrgNodeRead:
|
||||
node = db.get(Department, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="组织节点不存在")
|
||||
node_type = getattr(node, "org_node_type", None) or "DEPARTMENT"
|
||||
if node_type not in {"DEPARTMENT", "GROUP"}:
|
||||
raise HTTPException(status_code=400, detail="只有部门或小组节点可以新增人员")
|
||||
employee = db.get(Employee, payload.employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail="人员不存在或已停用")
|
||||
|
||||
existing = db.scalar(
|
||||
select(OrgEmployeeBinding).where(
|
||||
OrgEmployeeBinding.dept_id == node_id,
|
||||
OrgEmployeeBinding.employee_id == payload.employee_id,
|
||||
)
|
||||
)
|
||||
now = _now()
|
||||
if existing:
|
||||
existing.status = "ACTIVE"
|
||||
existing.sort_no = payload.sort_no
|
||||
existing.updated_at = now
|
||||
db.add(existing)
|
||||
else:
|
||||
db.add(
|
||||
OrgEmployeeBinding(
|
||||
dept_id=node_id,
|
||||
employee_id=payload.employee_id,
|
||||
sort_no=payload.sort_no,
|
||||
status="ACTIVE",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == node_id)
|
||||
|
||||
|
||||
def unbind_org_employee(db: Session, node_id: int, employee_id: int) -> OrgNodeRead:
|
||||
binding = db.scalar(
|
||||
select(OrgEmployeeBinding).where(
|
||||
OrgEmployeeBinding.dept_id == node_id,
|
||||
OrgEmployeeBinding.employee_id == employee_id,
|
||||
OrgEmployeeBinding.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
if not binding:
|
||||
raise HTTPException(status_code=404, detail="人员节点不存在")
|
||||
db.delete(binding)
|
||||
db.commit()
|
||||
return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == node_id)
|
||||
|
||||
|
||||
def list_roles(db: Session) -> list[RoleRead]:
|
||||
roles = db.scalars(select(Role).order_by(Role.id)).all()
|
||||
if not roles:
|
||||
return []
|
||||
role_ids = [role.id for role in roles]
|
||||
permission_rows = db.execute(
|
||||
select(RolePermission.role_id, Permission.permission_code)
|
||||
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||
.where(RolePermission.role_id.in_(role_ids))
|
||||
.order_by(RolePermission.role_id, Permission.permission_code)
|
||||
).all()
|
||||
user_counts = dict(
|
||||
db.execute(
|
||||
select(UserRole.role_id, func.count(UserRole.user_id))
|
||||
.where(UserRole.role_id.in_(role_ids))
|
||||
.group_by(UserRole.role_id)
|
||||
).all()
|
||||
)
|
||||
permission_map: dict[int, list[str]] = {}
|
||||
for role_id, code in permission_rows:
|
||||
permission_map.setdefault(role_id, []).append(code)
|
||||
return [
|
||||
RoleRead(
|
||||
role_id=role.id,
|
||||
role_code=role.role_code,
|
||||
role_name=role.role_name,
|
||||
role_scope=role.role_scope,
|
||||
status=role.status,
|
||||
remark=role.remark,
|
||||
permission_codes=permission_map.get(role.id, []),
|
||||
user_count=int(user_counts.get(role.id, 0)),
|
||||
created_at=role.created_at,
|
||||
updated_at=role.updated_at,
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _replace_role_permissions(db: Session, role_id: int, permission_codes: list[str]) -> None:
|
||||
permissions = _permissions_for_codes(db, permission_codes)
|
||||
db.execute(delete(RolePermission).where(RolePermission.role_id == role_id))
|
||||
now = _now()
|
||||
for permission in permissions:
|
||||
db.add(RolePermission(role_id=role_id, permission_id=permission.id, created_at=now))
|
||||
|
||||
|
||||
def _permissions_for_codes(db: Session, permission_codes: list[str]) -> list[Permission]:
|
||||
unique_codes = list(dict.fromkeys(permission_codes))
|
||||
permissions = (
|
||||
db.scalars(select(Permission).where(Permission.permission_code.in_(unique_codes))).all()
|
||||
if unique_codes
|
||||
else []
|
||||
)
|
||||
missing = sorted(set(unique_codes) - {row.permission_code for row in permissions})
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"权限不存在:{'、'.join(missing)}")
|
||||
return permissions
|
||||
|
||||
|
||||
def create_role(db: Session, payload: RoleCreate) -> RoleRead:
|
||||
if db.scalar(select(Role).where(Role.role_code == payload.role_code)):
|
||||
raise HTTPException(status_code=400, detail="角色编码已存在")
|
||||
_permissions_for_codes(db, payload.permission_codes)
|
||||
now = _now()
|
||||
row = Role(
|
||||
role_code=payload.role_code,
|
||||
role_name=payload.role_name,
|
||||
role_scope=payload.role_scope,
|
||||
status=payload.status,
|
||||
remark=payload.remark,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
_replace_role_permissions(db, row.id, payload.permission_codes)
|
||||
db.commit()
|
||||
return next(role for role in list_roles(db) if role.role_id == row.id)
|
||||
|
||||
|
||||
def update_role(db: Session, role_id: int, payload: RoleUpdate) -> RoleRead:
|
||||
row = db.get(Role, role_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
_permissions_for_codes(db, payload.permission_codes)
|
||||
row.role_name = payload.role_name
|
||||
row.role_scope = payload.role_scope
|
||||
row.status = payload.status
|
||||
row.remark = payload.remark
|
||||
row.updated_at = _now()
|
||||
db.add(row)
|
||||
_replace_role_permissions(db, row.id, payload.permission_codes)
|
||||
db.commit()
|
||||
return next(role for role in list_roles(db) if role.role_id == row.id)
|
||||
|
||||
|
||||
def _system_user_read(db: Session, user: User) -> SystemUserRead:
|
||||
employee = db.get(Employee, user.employee_id)
|
||||
department = db.get(Department, user.dept_id)
|
||||
role_rows = db.execute(
|
||||
select(Role.id, Role.role_name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user.id)
|
||||
.order_by(Role.id)
|
||||
).all()
|
||||
return SystemUserRead(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
employee_id=user.employee_id,
|
||||
employee_name=employee.employee_name if employee else user.nickname or user.username,
|
||||
dept_id=user.dept_id,
|
||||
dept_name=department.dept_name if department else None,
|
||||
mobile=employee.mobile if employee else None,
|
||||
nickname=user.nickname,
|
||||
is_super_admin=bool(user.is_super_admin),
|
||||
status=user.status,
|
||||
role_ids=[row[0] for row in role_rows],
|
||||
role_names=[row[1] for row in role_rows],
|
||||
last_login_at=user.last_login_at,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def list_system_users(db: Session) -> list[SystemUserRead]:
|
||||
rows = db.scalars(select(User).order_by(User.status, User.id)).all()
|
||||
return [_system_user_read(db, row) for row in rows]
|
||||
|
||||
|
||||
def _permission_by_code(db: Session, permission_code: str) -> Permission:
|
||||
permission = db.scalar(
|
||||
select(Permission).where(
|
||||
Permission.permission_code == permission_code,
|
||||
Permission.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
if not permission:
|
||||
raise HTTPException(status_code=400, detail=f"权限码不存在或已停用:{permission_code}")
|
||||
return permission
|
||||
|
||||
|
||||
def _employee_role_permission_maps(
|
||||
db: Session,
|
||||
employee_ids: list[int],
|
||||
) -> tuple[dict[int, User], dict[int, list[str]], dict[int, list[str]]]:
|
||||
if not employee_ids:
|
||||
return {}, {}, {}
|
||||
users = db.scalars(
|
||||
select(User)
|
||||
.where(
|
||||
User.employee_id.in_(employee_ids),
|
||||
User.status == "ACTIVE",
|
||||
)
|
||||
.order_by(User.id)
|
||||
).all()
|
||||
user_by_employee_id = {user.employee_id: user for user in users}
|
||||
if not users:
|
||||
return {}, {}, {}
|
||||
rows = db.execute(
|
||||
select(User.employee_id, Role.role_name, Permission.permission_code)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.outerjoin(RolePermission, RolePermission.role_id == Role.id)
|
||||
.outerjoin(
|
||||
Permission,
|
||||
and_(Permission.id == RolePermission.permission_id, Permission.status == "ACTIVE"),
|
||||
)
|
||||
.where(
|
||||
User.id.in_([user.id for user in users]),
|
||||
Role.status == "ACTIVE",
|
||||
)
|
||||
.order_by(User.employee_id, Role.id, Permission.permission_code)
|
||||
).all()
|
||||
role_map: dict[int, list[str]] = {}
|
||||
permission_map: dict[int, list[str]] = {}
|
||||
for employee_id, role_name, permission_code in rows:
|
||||
if role_name and role_name not in role_map.setdefault(employee_id, []):
|
||||
role_map[employee_id].append(role_name)
|
||||
if permission_code and permission_code not in permission_map.setdefault(employee_id, []):
|
||||
permission_map[employee_id].append(permission_code)
|
||||
return user_by_employee_id, role_map, permission_map
|
||||
|
||||
|
||||
def employee_has_permission(db: Session, employee_id: int, permission_code: str) -> bool:
|
||||
permission = _permission_by_code(db, permission_code)
|
||||
user = db.scalar(
|
||||
select(User).where(
|
||||
User.employee_id == employee_id,
|
||||
User.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
if not user:
|
||||
return False
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
return False
|
||||
if bool(user.is_super_admin):
|
||||
return True
|
||||
return bool(
|
||||
db.scalar(
|
||||
select(
|
||||
exists()
|
||||
.where(UserRole.user_id == user.id)
|
||||
.where(UserRole.role_id == Role.id)
|
||||
.where(Role.status == "ACTIVE")
|
||||
.where(RolePermission.role_id == Role.id)
|
||||
.where(RolePermission.permission_id == permission.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ensure_employee_has_permission(
|
||||
db: Session,
|
||||
employee_id: int | None,
|
||||
permission_code: str,
|
||||
field_label: str,
|
||||
*,
|
||||
required: bool = False,
|
||||
) -> None:
|
||||
if employee_id is None:
|
||||
if required:
|
||||
raise HTTPException(status_code=400, detail=f"{field_label}必填")
|
||||
return
|
||||
if employee_id <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"{field_label}不存在或已停用")
|
||||
permission = _permission_by_code(db, permission_code)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail=f"{field_label}不存在或已停用")
|
||||
if not employee_has_permission(db, employee_id, permission_code):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{field_label}必须选择有“{permission.permission_name}”权限的人员",
|
||||
)
|
||||
|
||||
|
||||
def list_employee_options(
|
||||
db: Session,
|
||||
permission_code: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> list[EmployeeOption]:
|
||||
permission = _permission_by_code(db, permission_code) if permission_code else None
|
||||
stmt = select(Employee).where(Employee.status == "ACTIVE")
|
||||
if permission:
|
||||
permitted_user_exists = (
|
||||
exists()
|
||||
.where(User.employee_id == Employee.id)
|
||||
.where(User.status == "ACTIVE")
|
||||
.where(
|
||||
or_(
|
||||
User.is_super_admin == 1,
|
||||
exists()
|
||||
.where(UserRole.user_id == User.id)
|
||||
.where(UserRole.role_id == Role.id)
|
||||
.where(Role.status == "ACTIVE")
|
||||
.where(RolePermission.role_id == Role.id)
|
||||
.where(RolePermission.permission_id == permission.id),
|
||||
)
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(permitted_user_exists)
|
||||
employees = sorted(
|
||||
db.scalars(stmt.order_by(Employee.id)).all(),
|
||||
key=_employee_name_sort_key,
|
||||
)[:limit]
|
||||
dept_map = {row.id: row.dept_name for row in db.scalars(select(Department)).all()}
|
||||
employee_ids = [employee.id for employee in employees]
|
||||
user_map, role_map, permission_map = _employee_role_permission_maps(db, employee_ids)
|
||||
return [
|
||||
EmployeeOption(
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.employee_name,
|
||||
dept_id=employee.dept_id,
|
||||
dept_name=dept_map.get(employee.dept_id),
|
||||
mobile=employee.mobile,
|
||||
job_title=employee.job_title,
|
||||
username=user_map[employee.id].username if employee.id in user_map else None,
|
||||
role_names=role_map.get(employee.id, []),
|
||||
permission_codes=permission_map.get(employee.id, []),
|
||||
has_system_user=employee.id in user_map,
|
||||
)
|
||||
for employee in employees
|
||||
]
|
||||
|
||||
|
||||
def _replace_user_roles(db: Session, user_id: int, role_ids: list[int]) -> None:
|
||||
roles = _roles_for_ids(db, role_ids)
|
||||
db.execute(delete(UserRole).where(UserRole.user_id == user_id))
|
||||
now = _now()
|
||||
for role in roles:
|
||||
db.add(UserRole(user_id=user_id, role_id=role.id, created_at=now))
|
||||
|
||||
|
||||
def _roles_for_ids(db: Session, role_ids: list[int]) -> list[Role]:
|
||||
unique_role_ids = list(dict.fromkeys(role_ids))
|
||||
roles = db.scalars(select(Role).where(Role.id.in_(unique_role_ids), Role.status == "ACTIVE")).all()
|
||||
missing = sorted(set(unique_role_ids) - {row.id for row in roles})
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"角色不存在或已停用:{missing}")
|
||||
return roles
|
||||
|
||||
|
||||
def create_system_user(db: Session, payload: SystemUserCreate) -> SystemUserRead:
|
||||
employee = db.get(Employee, payload.employee_id)
|
||||
if not employee or employee.status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail="人员不存在或已停用")
|
||||
username = _normalize_phone(employee.mobile)
|
||||
if not username:
|
||||
raise HTTPException(status_code=400, detail="人员没有电话号码,不能创建系统账号")
|
||||
if db.scalar(select(User).where(User.employee_id == employee.id)):
|
||||
raise HTTPException(status_code=400, detail="该人员已经有系统账号")
|
||||
if db.scalar(select(User).where(User.username == username)):
|
||||
raise HTTPException(status_code=400, detail="该电话号码已经作为系统账号存在")
|
||||
_roles_for_ids(db, payload.role_ids)
|
||||
now = _now()
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(payload.password),
|
||||
employee_id=employee.id,
|
||||
dept_id=employee.dept_id,
|
||||
nickname=employee.employee_name,
|
||||
email=None,
|
||||
is_super_admin=0,
|
||||
last_login_at=None,
|
||||
status=payload.status,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
_replace_user_roles(db, user.id, payload.role_ids)
|
||||
db.commit()
|
||||
return _system_user_read(db, user)
|
||||
|
||||
|
||||
def update_system_user_roles(db: Session, user_id: int, role_ids: list[int]) -> SystemUserRead:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="系统用户不存在")
|
||||
_replace_user_roles(db, user.id, role_ids)
|
||||
user.updated_at = _now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return _system_user_read(db, user)
|
||||
|
||||
|
||||
def update_system_user_status(db: Session, user_id: int, status: str, current_user_id: int) -> SystemUserRead:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="系统用户不存在")
|
||||
if user.id == current_user_id and status != "ACTIVE":
|
||||
raise HTTPException(status_code=400, detail="不能停用当前登录账号")
|
||||
user.status = status
|
||||
user.updated_at = _now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return _system_user_read(db, user)
|
||||
|
||||
|
||||
def reset_system_user_password(db: Session, user_id: int, password: str) -> SystemUserRead:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="系统用户不存在")
|
||||
user.password_hash = hash_password(password)
|
||||
user.updated_at = _now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return _system_user_read(db, user)
|
||||
Reference in New Issue
Block a user