Files
KCGL/inventory-backend/app/services/inbound/repair_service.py

294 lines
11 KiB
Python
Raw Normal View History

# inventory-backend/app/services/inbound/repair_service.py
from app.extensions import db
from app.models.transaction import TransRepair
from app.models.base import MaterialBase
from datetime import datetime, timezone
from sqlalchemy import text
class RepairInboundService:
@staticmethod
def _generate_repair_no():
"""
生成唯一的维修单号
格式: REP-YYYYMMDD-0001 (按天递增)
策略: 查询当天最大的repair_no,提取流水号+1
"""
today = datetime.now().strftime('%Y%m%d')
prefix = f"REP-{today}-"
# 查询当天最大的维修单号
latest = TransRepair.query.filter(
TransRepair.repair_no.like(f"{prefix}%")
).order_by(TransRepair.repair_no.desc()).first()
if latest and latest.repair_no:
try:
# 提取最后的流水号
last_seq = int(latest.repair_no.split('-')[-1])
new_seq = last_seq + 1
except (ValueError, IndexError):
new_seq = 1
else:
new_seq = 1
return f"{prefix}{new_seq:04d}"
@staticmethod
def _generate_sku():
"""
获取全局自增序列号,生成10位SKU
格式: str(seq).zfill(10)
"""
try:
seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql)
next_global_id = result.scalar()
return str(next_global_id).zfill(10) if next_global_id else None
except:
return None
@staticmethod
def get_list(params):
"""
获取维修单列表
支持按 repair_no, sku, material_name, serial_number, repair_status 模糊查询
实现分页
"""
page = params.get('page', 1)
page_size = params.get('page_size', 20)
query = TransRepair.query
# 模糊查询条件
if params.get('repair_no'):
query = query.filter(TransRepair.repair_no.ilike(f"%{params['repair_no']}%"))
if params.get('sku'):
query = query.filter(TransRepair.sku.ilike(f"%{params['sku']}%"))
if params.get('serial_number'):
query = query.filter(TransRepair.serial_number.ilike(f"%{params['serial_number']}%"))
if params.get('repair_status'):
status_value = params['repair_status']
if status_value == '未出库':
# 未出库:排除已出库状态
query = query.filter(TransRepair.repair_status != '已出库')
elif status_value not in ['全部', '']:
# 其他明确状态:精确匹配
query = query.filter(TransRepair.repair_status == status_value)
# '全部' 或为空:不过滤状态
# 关联 MaterialBase 查询物料名称 或 直接搜索 TransRepair.material_name
if params.get('material_name'):
material_name_filter = params['material_name']
# 优先搜索直接存储的 material_name,其次搜索关联的 base.name
query = query.outerjoin(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
db.or_(
TransRepair.material_name.ilike(f"%{material_name_filter}%"),
MaterialBase.name.ilike(f"%{material_name_filter}%")
)
)
# 按接收时间升序(先进先出)+ id 升序
query = query.order_by(db.asc(TransRepair.arrival_date), db.asc(TransRepair.id))
# ============================================================
# 【全局特权】基于 JWT 与 global:cross_company_op 的跨组织隔离
# ============================================================
from flask_jwt_extended import get_jwt
claims = get_jwt()
user_role = claims.get('role', '').upper() if claims.get('role') else ''
user_company = claims.get('company_name', '')
# 获取用户权限列表(用于检查 global:cross_company_op 特权)
from app.api.v1.inbound.base import get_current_user_permissions
user_perms = get_current_user_permissions() or []
normalized_perms = set(p.lower().replace('_', '').replace(':', '') for p in user_perms)
# 检查是否拥有全局特权或超管角色(crossDomain 与 global:cross_company_op 都视为跨域)
has_cross_company = 'globalcrosscompanyop' in normalized_perms or 'crossdomain' in normalized_perms
# 维修表需要通过 base_id 关联 MaterialBase 进行公司过滤
if user_role != 'SUPER_ADMIN' and not has_cross_company:
# 无特权:强制绑定本公司
query = query.outerjoin(MaterialBase, TransRepair.base_id == MaterialBase.id)
if user_company:
query = query.filter(MaterialBase.company_name == user_company)
# 分页
pagination = query.paginate(page=page, per_page=page_size, error_out=False)
items = []
for item in pagination.items:
item_dict = item.to_dict()
# 如果有 base_id,尝试获取物料名称
if item.base_id:
base = MaterialBase.query.get(item.base_id)
if base:
item_dict['material_name'] = base.name
item_dict['company_name'] = base.company_name
items.append(item_dict)
return {
'list': items,
'total': pagination.total,
'page': page,
'page_size': page_size
}
@staticmethod
def create(data):
"""
新增维修单
核心要求:
1. 生成以 REP- 打头的自增维修单号 (按天递增)
2. 从全局序列获取10位SKU (global_print_seq)
3. 支持不关联 base_id (独立录入模式)
4. 新增客户名称和客户所在地字段
"""
# 生成维修单号
repair_no = RepairInboundService._generate_repair_no()
# 获取全局SKU
sku = data.get('sku')
if not sku:
sku = RepairInboundService._generate_sku()
# 获取物料信息 (可选)
material_name = data.get('material_name')
company_name = None
if data.get('base_id'):
base = MaterialBase.query.get(data['base_id'])
if base:
material_name = base.name or material_name
company_name = base.company_name
if not sku:
sku = base.code
repair = TransRepair(
repair_no=repair_no,
base_id=data.get('base_id'),
sku=sku,
material_name=material_name,
serial_number=data.get('serial_number'),
arrival_date=data.get('arrival_date'),
repair_status=data.get('repair_status', '待检测'),
fault_description=data.get('fault_description'),
expected_repair_time=data.get('expected_repair_time'),
repair_result=data.get('repair_result'),
repair_manager=data.get('repair_manager'),
shipping_date=data.get('shipping_date'),
related_contract_id=data.get('related_contract_id'),
# 新增客户字段
customer_name=data.get('customer_name'),
customer_location=data.get('customer_location'),
cost_price=data.get('cost_price'),
sale_price=data.get('sale_price'),
company_id=data.get('company_id'),
source_table=data.get('source_table'),
stock_id=data.get('stock_id'),
is_self_made=data.get('is_self_made', False),
related_product_id=data.get('related_product_id'),
)
db.session.add(repair)
db.session.commit()
result = repair.to_dict()
result['material_name'] = material_name
result['company_name'] = company_name
return result
@staticmethod
def update(id, data):
"""
更新维修单
"""
repair = TransRepair.query.get(id)
if not repair:
return None
# 可更新字段
updatable_fields = [
'base_id', 'sku', 'material_name', 'serial_number', 'arrival_date', 'repair_status',
'fault_description', 'expected_repair_time', 'repair_result',
'repair_manager', 'shipping_date', 'related_contract_id',
'customer_name', 'customer_location', 'cost_price', 'sale_price', 'company_id'
]
for field in updatable_fields:
if field in data:
setattr(repair, field, data[field])
db.session.commit()
return repair.to_dict()
@staticmethod
def update_status(id, status, repair_log=None):
"""
专门用于更新维修状态和追加维修日志
"""
# 禁止手动变更为已出库状态,必须通过扫码出库模块进行
if status == '已出库':
raise ValueError("禁止手动变更为已出库状态,请通过扫码出库模块进行操作")
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
# 禁止手动变更为报废转出状态。
#
# ★ 原文案是「请前往报废管理进行扫码操作」,但那条路已不存在:
# 维修件报废原本由「直接报废」接口(POST /api/v1/scrap)的
# trans_repair 分支实现,而该接口绕过审批、与系统自陈的
# 「报废一律需审批」规则冲突,已整体移除。审批流的扫码校验
# 也从来拒绝 trans_* 来源,故这条链路早已不通。
#
# 维修件报废的正式支持(含数量语义、审批接入)需单独排期。
if status == '报废转出':
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
raise ValueError(
"禁止手动变更为报废状态。维修件报废暂未开放,请联系管理员"
)
repair = TransRepair.query.get(id)
if not repair:
return None
repair.repair_status = status
# 追加维修日志
if repair_log:
if repair.repair_result:
repair.repair_result = repair.repair_result + '\n' + f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {repair_log}"
else:
repair.repair_result = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {repair_log}"
db.session.commit()
return repair.to_dict()
@staticmethod
def delete(id):
"""
删除维修单
"""
repair = TransRepair.query.get(id)
if not repair:
return False
db.session.delete(repair)
db.session.commit()
return True
@staticmethod
def get_by_id(id):
"""
根据ID获取维修单详情
"""
repair = TransRepair.query.get(id)
if not repair:
return None
item_dict = repair.to_dict()
if repair.base_id:
base = MaterialBase.query.get(repair.base_id)
if base:
item_dict['material_name'] = base.name
item_dict['company_name'] = base.company_name
return item_dict