Initial commit

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