Compare commits
6 Commits
483fb5336a
...
07567d2f76
| Author | SHA1 | Date | |
|---|---|---|---|
| 07567d2f76 | |||
| 0005a689dc | |||
| 05524c988a | |||
| 27e5589a5e | |||
| 9d187593eb | |||
| 7071c89305 |
65
db_migrations/phase7_outbound_reissue.sql
Normal file
65
db_migrations/phase7_outbound_reissue.sql
Normal file
@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- 出库 · 原单退回后的「补发单」关联
|
||||
--
|
||||
-- 背景
|
||||
-- 原单退回目前只做两件事:良品加回库存 / 不良品转在管台账。
|
||||
-- 但**申请人的需求并没有被满足** —— 东西交回来了(甚至还是坏的),
|
||||
-- 系统却:不提醒任何人、无单据承载「我要重新领一份」、退回与后续再出库
|
||||
-- 之间毫无关联。现场只能靠人记住再手动新建一张出库申请,且那张单与原单
|
||||
-- 看不出任何关系。
|
||||
--
|
||||
-- 本次改动
|
||||
-- outbound_approval 新增 source_return_id:非空即表示这是一张由「原单退回」
|
||||
-- 自动生成的**补发单**,从而把「退回 → 补发」串成闭环,出库记录里也能
|
||||
-- 一眼看出哪张单是补发的。
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ★ 为什么不加 is_reissue 布尔列
|
||||
-- 「是不是补发」完全由「来源退回单是否存在」决定,再加一个布尔列就是
|
||||
-- 同一事实的两处存储,必然有不同步的一天(改了 A 忘了 B)。
|
||||
-- 单列既表达事实,又能直接 join 回原单。
|
||||
--
|
||||
-- ★ 为什么只存退回单 ID,不另存原出库单 ID
|
||||
-- trans_return 本身已有 outbound_id,顺着 source_return_id 一跳即可拿到
|
||||
-- 原出库明细。冗余存储只会带来「两张单对不上」的风险。
|
||||
--
|
||||
-- 幂等:带 IF NOT EXISTS,可重复执行。不含 psql 元命令,DataGrip 可直接执行。
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE outbound_approval
|
||||
ADD COLUMN IF NOT EXISTS source_return_id integer;
|
||||
|
||||
COMMENT ON COLUMN outbound_approval.source_return_id IS
|
||||
'补发单来源:trans_return.id。非空即表示本单是由「原单退回」自动生成的补发单';
|
||||
|
||||
-- 支撑「这批补发是从哪张退回单来的」与列表标识
|
||||
CREATE INDEX IF NOT EXISTS ix_outbound_approval_source_return
|
||||
ON outbound_approval (source_return_id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
SELECT '=== 1) 新列已就位 ===' AS "核对项";
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'outbound_approval' AND column_name = 'source_return_id';
|
||||
|
||||
SELECT '=== 2) 索引已就位 ===' AS "核对项";
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'outbound_approval' AND indexname = 'ix_outbound_approval_source_return';
|
||||
|
||||
SELECT '=== 3) 存量补发单(新功能上线前应为 0)===' AS "核对项";
|
||||
SELECT count(*) AS 补发单数 FROM outbound_approval WHERE source_return_id IS NOT NULL;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DROP INDEX IF EXISTS ix_outbound_approval_source_return;
|
||||
-- ALTER TABLE outbound_approval DROP COLUMN IF EXISTS source_return_id;
|
||||
-- COMMIT;
|
||||
67
db_migrations/phase8_trans_outbound_applicant.sql
Normal file
67
db_migrations/phase8_trans_outbound_applicant.sql
Normal file
@ -0,0 +1,67 @@
|
||||
-- =============================================================================
|
||||
-- 出库明细补记「申请人」,让退回补发能找回真正该拿东西的人
|
||||
--
|
||||
-- 问题
|
||||
-- 退回后勾选补发时,补发单的申请人无从确定 —— trans_outbound 只有
|
||||
-- consumer_name(**扫码时前端自由填写**的领用人/客户名,既不可靠也可能是
|
||||
-- 客户),没有任何指回原审批单的关联。原先只能回退成「当前操作人(库管)」,
|
||||
-- 但**补发是原申请人的需求**,挂在库管名下逻辑不通。
|
||||
--
|
||||
-- 本次改动
|
||||
-- trans_outbound 新增 applicant_id,创建出库时从审批单带出。
|
||||
-- 创建出库时 approval 恒非 None(request_id 已强制必填),故新单据必然有值。
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ★ 为什么从审批单带,而不是从 consumer_name 反查
|
||||
-- consumer_name 是自由文本、可能是客户名,按姓名反查会重蹈「重名错绑」
|
||||
-- 的覆辙(借用人姓名回填那轮刚踩过)。审批单的 applicant_id 是**主键**,
|
||||
-- 没有歧义。
|
||||
--
|
||||
-- ★ 为什么不回填存量行
|
||||
-- 存量出库明细与其来源审批单之间**没有任何可用的关联**(创建时只把审批单
|
||||
-- 状态置为 3,没落任何外键),无从回填。刻意留 NULL 而不是按姓名猜 ——
|
||||
-- NULL 表示「这张单产生于本列上线之前」,退回时由库管在选择器里明确指定。
|
||||
-- 与 dispatch_operator 的处理同一取舍:宁可留空,也不猜。
|
||||
--
|
||||
-- 幂等:带 IF NOT EXISTS,可重复执行。不含 psql 元命令,DataGrip 可直接执行。
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE trans_outbound
|
||||
ADD COLUMN IF NOT EXISTS applicant_id integer;
|
||||
|
||||
COMMENT ON COLUMN trans_outbound.applicant_id IS
|
||||
'申请人ID,创建出库时从关联审批单带出。该列上线前的历史行为 NULL(无从回填)';
|
||||
|
||||
-- 支撑「某人申请过的出库」类查询与退回补发的申请人回查
|
||||
CREATE INDEX IF NOT EXISTS ix_trans_outbound_applicant
|
||||
ON trans_outbound (applicant_id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
SELECT '=== 1) 新列已就位 ===' AS "核对项";
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'trans_outbound' AND column_name = 'applicant_id';
|
||||
|
||||
SELECT '=== 2) 索引已就位 ===' AS "核对项";
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'trans_outbound' AND indexname = 'ix_trans_outbound_applicant';
|
||||
|
||||
SELECT '=== 3) 存量行应全部为 NULL(历史无从回填)===' AS "核对项";
|
||||
SELECT count(*) AS 出库明细总数,
|
||||
count(applicant_id) AS 已记录申请人
|
||||
FROM trans_outbound;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DROP INDEX IF EXISTS ix_trans_outbound_applicant;
|
||||
-- ALTER TABLE trans_outbound DROP COLUMN IF EXISTS applicant_id;
|
||||
-- COMMIT;
|
||||
@ -5,3 +5,4 @@ common_bp = Blueprint('common', __name__)
|
||||
|
||||
# 导入子模块,使其路由装饰器注册到 common_bp
|
||||
from . import search
|
||||
from . import users # noqa: F401 「选择某人」类下拉框的共用人员名单
|
||||
|
||||
46
inventory-backend/app/api/v1/common/users.py
Normal file
46
inventory-backend/app/api/v1/common/users.py
Normal file
@ -0,0 +1,46 @@
|
||||
# inventory-backend/app/api/v1/common/users.py
|
||||
from flask import jsonify
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from . import common_bp
|
||||
|
||||
|
||||
def active_user_options():
|
||||
"""
|
||||
在职人员名单(id + 姓名)——「选择某人」类下拉框的**共用实现**。
|
||||
|
||||
★ 为什么单独开一条中性路径,而不是复用 /transactions/borrow/users:
|
||||
那条路径在语义上属于借库模块,出库补发、报废执行等处若直接复用,
|
||||
后人读代码时会困惑「出库为什么在调借库的接口」。这里提供统一入口,
|
||||
借库那条路径改为调本函数,实现只有一份。
|
||||
|
||||
★ 公司隔离与业务台账同口径(get_current_company_filter):
|
||||
否则 A 公司的人能在选择器里看到 B 公司人员。
|
||||
|
||||
★ 只返回 id 与姓名:不含邮箱 / 角色 / 部门,最小披露。
|
||||
"""
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
from app.models.system import SysUser
|
||||
from app.services.trans_service import user_display_name
|
||||
|
||||
company_limit = get_current_company_filter()
|
||||
query = SysUser.query.filter(SysUser.status == 'active')
|
||||
if company_limit is not None:
|
||||
# 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度
|
||||
query = query.filter(SysUser.department == company_limit)
|
||||
|
||||
return [{'id': u.id, 'name': user_display_name(u)}
|
||||
for u in query.order_by(SysUser.username).all()]
|
||||
|
||||
|
||||
@common_bp.route('/active-users', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_active_users():
|
||||
"""
|
||||
在职人员名单,供借出 / 转交 / 归还 / 出库补发等多个页面的选择器共用。
|
||||
|
||||
★ 无 permission_required,仅要求登录:
|
||||
同一份名单要被多个页面共用,绑定其中任一权限码都会让其他页面 403;
|
||||
且只暴露 id 与姓名(与 /auth/users/approvers 同一处理方式)。
|
||||
"""
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': active_user_options()})
|
||||
@ -2766,12 +2766,21 @@ def return_from_outbound():
|
||||
"outbound_id": 123, # 必填,trans_outbound.id(出库**明细行**,非单号)
|
||||
"return_qty": 2, # 必填,本次退回数量
|
||||
"is_defective": false, # 必填,true=不良品退回,false=良品退回
|
||||
"reason": "错领退回" # 可选
|
||||
"reason": "错领退回", # 可选
|
||||
"need_reissue": true, # 可选,退回后是否自动生成补发单
|
||||
"reissue_qty": 2, # 可选,补发数量,默认 = return_qty
|
||||
"reissue_applicant_id": 12 # 可选,补发给谁;不传则=当前操作人
|
||||
}
|
||||
|
||||
两条分支的差异:
|
||||
· 良品 → 加回原库存行的 stock_quantity 与 available_quantity
|
||||
· 不良品 → 库存表分毫不动,转 trans_defective_goods 在管台账
|
||||
|
||||
补发(need_reissue=true):
|
||||
· 自动生成一张**免审批**出库单(status=1,直接进入待执行),
|
||||
申请人 = 原出库单的申请人,并关联 source_return_id 回本笔退回;
|
||||
· 提交即预占库存(strict),**不足则整笔退回一并回滚**并返回明确提示 ——
|
||||
若只让补发静默失败,「需要补发」的意图就丢了。库管可取消勾选后重试。
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
operator_name = _normalize_user_id()
|
||||
@ -2779,6 +2788,11 @@ def return_from_outbound():
|
||||
outbound_id = data.get('outbound_id')
|
||||
is_defective = data.get('is_defective')
|
||||
reason = (data.get('reason') or '').strip() or None
|
||||
# 补发(可选):退回后申请人往往仍需这件东西。勾选则自动生成一张免审批出库单。
|
||||
need_reissue = bool(data.get('need_reissue'))
|
||||
reissue_qty = data.get('reissue_qty')
|
||||
# 补发给谁:不传则回退为当前操作人(见下方补发块)
|
||||
reissue_applicant_id = data.get('reissue_applicant_id')
|
||||
|
||||
# ---- 1. 入参校验(脏值一律挡在入口)----
|
||||
if not outbound_id:
|
||||
@ -2882,11 +2896,102 @@ def return_from_outbound():
|
||||
if goods is not None:
|
||||
goods.return_id = ledger.id
|
||||
|
||||
# ==================================================================
|
||||
# ---- 5. 补发(可选)----
|
||||
# 退回后申请人往往**仍然需要这件东西**(尤其是坏件 —— 原需求并未
|
||||
# 被满足)。勾选即自动生成一张**免审批**的出库单并关联回本笔退回,
|
||||
# 使「退回 → 补发」形成闭环;否则现场只能靠人记住再手建一张单,
|
||||
# 而那张单与原单看不出任何关系。
|
||||
#
|
||||
# ★ 库存不足时**整笔回滚**(下面的 reserve_for_items 会抛错)。
|
||||
# 若只让补发静默失败,「需要补发」的意图就丢了 —— 那正是本功能
|
||||
# 要解决的问题。回滚后库管会看到明确提示,可取消勾选重试。
|
||||
# ==================================================================
|
||||
reissue = None
|
||||
if need_reissue:
|
||||
# 单号生成器在 OutboundApprovalService 上(不在 OutboundService)
|
||||
from app.services.outbound_service import OutboundApprovalService
|
||||
from app.services.inventory_reservation import reserve_for_items
|
||||
from app.models.outbound import OutboundApproval
|
||||
|
||||
if reissue_qty is None:
|
||||
reissue_qty = return_qty # 默认与本次退回量一致
|
||||
try:
|
||||
reissue_qty = float(reissue_qty)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('补发数量格式无效')
|
||||
if reissue_qty <= 0:
|
||||
raise ValueError('补发数量必须大于 0')
|
||||
if reissue_qty > return_qty:
|
||||
raise ValueError(
|
||||
f'补发数量({reissue_qty})不能大于本次退回数量({return_qty})'
|
||||
)
|
||||
|
||||
base = getattr(stock_row, 'base', None)
|
||||
if base is None:
|
||||
raise ValueError('原库存行的物料主数据已不存在,无法生成补发单')
|
||||
|
||||
# 提交即预占,strict=True —— 与出库申请同一口径,不足即整单失败
|
||||
reserved_items, _shortages = reserve_for_items(
|
||||
[{
|
||||
'base_id': base.id,
|
||||
'name': base.name or '',
|
||||
'spec_model': base.spec_model or '',
|
||||
'quantity': reissue_qty,
|
||||
}],
|
||||
company_limit=get_current_company_filter(),
|
||||
strict=True,
|
||||
)
|
||||
|
||||
# ★ 申请人(补发给谁)优先级:
|
||||
# ① 前端显式指定 reissue_applicant_id —— 现场最清楚该给谁;
|
||||
# ② 回退到原出库明细记录的 applicant_id(创建出库时从审批单带出的
|
||||
# 真实原申请人);
|
||||
# ③ 两者都没有 → **报错要求指定**。
|
||||
#
|
||||
# ★ 绝不回退为「当前操作人」:补发是**原申请人的需求**,挂到办理
|
||||
# 退回的库管名下逻辑不通 —— 那张单会出现在库管的「我的申请」里,
|
||||
# 而真正该拿东西的人什么也看不到。
|
||||
# ⚠ 存量出库明细的 applicant_id 为 NULL(历史无从回填),此时必须由
|
||||
# 库管在选择器里明确指定 —— 宁可多一步,也不猜错人。
|
||||
if reissue_applicant_id:
|
||||
try:
|
||||
_applicant = int(reissue_applicant_id)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('补发申请人ID格式无效')
|
||||
from app.models.system import SysUser
|
||||
if not SysUser.query.get(_applicant):
|
||||
raise ValueError(f'补发申请人不存在(ID:{reissue_applicant_id})')
|
||||
elif outbound.applicant_id:
|
||||
_applicant = int(outbound.applicant_id)
|
||||
else:
|
||||
raise ValueError(
|
||||
'无法确定补发单申请人:这张出库单产生于「申请人」字段上线之前,'
|
||||
'请在上方选择「补发给谁」'
|
||||
)
|
||||
|
||||
reissue = OutboundApproval(
|
||||
request_no=OutboundApprovalService.generate_request_no(),
|
||||
applicant_id=_applicant,
|
||||
outbound_type=outbound.outbound_type,
|
||||
# 免审批:原需求已经批过一次,补发只是兑现它,重复审批是负担
|
||||
status=1,
|
||||
approved_at=beijing_time(),
|
||||
source_return_id=ledger.id,
|
||||
remark=(f'原单退回补发(原出库单 {outbound.outbound_no or outbound.id}'
|
||||
f',原领用人 {outbound.consumer_name or "未知"})'),
|
||||
)
|
||||
reissue.set_items(reserved_items)
|
||||
reissue.allowed_approvers = '[]'
|
||||
db.session.add(reissue)
|
||||
db.session.flush()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': f'退回成功,{outcome}',
|
||||
'msg': f'退回成功,{outcome}'
|
||||
+ (f';已生成补发单 {reissue.request_no}' if reissue else ''),
|
||||
'data': {
|
||||
'outbound_id': outbound.id,
|
||||
'return_id': ledger.id,
|
||||
@ -2895,6 +3000,12 @@ def return_from_outbound():
|
||||
'returned_quantity': float(outbound.returned_quantity),
|
||||
'returnable_quantity': shipped - float(outbound.returned_quantity),
|
||||
'defective_goods_id': goods.id if goods is not None else None,
|
||||
# 补发单(未勾选时为 null)
|
||||
'reissue': ({
|
||||
'id': reissue.id,
|
||||
'request_no': reissue.request_no,
|
||||
'quantity': reissue_qty,
|
||||
} if reissue else None),
|
||||
},
|
||||
}), 200
|
||||
|
||||
|
||||
@ -596,22 +596,13 @@ def get_borrow_user_options():
|
||||
★ 公司隔离与借用台账同口径(get_current_company_filter):
|
||||
否则 A 公司库管能在选择器里看到 B 公司人员,虽转交时会被 company
|
||||
校验二次拦截,但名单本身已属越权披露。
|
||||
|
||||
★ 实现已抽到 common.active_user_options(),与 /common/active-users 共用同一份
|
||||
逻辑 —— 出库补发等场景走那条中性路径,避免跨模块引用借库接口。
|
||||
"""
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
from app.models.system import SysUser
|
||||
from app.api.v1.common.users import active_user_options
|
||||
|
||||
company_limit = get_current_company_filter()
|
||||
query = SysUser.query.filter(SysUser.status == 'active')
|
||||
if company_limit is not None:
|
||||
# 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度
|
||||
query = query.filter(SysUser.department == company_limit)
|
||||
|
||||
users = query.order_by(SysUser.username).all()
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': [{'id': u.id, 'name': user_display_name(u)} for u in users],
|
||||
})
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': active_user_options()})
|
||||
|
||||
|
||||
# --- 发起借库转交(双向握手第一步)---
|
||||
|
||||
@ -34,6 +34,12 @@ class OutboundApproval(db.Model):
|
||||
# 明细快照 (存储出库物品的名称、规格、库位、数量等信息,无SKU字段)
|
||||
items_json = db.Column(db.Text)
|
||||
|
||||
# ★ 补发单来源:trans_return.id,非空即表示本单由「原单退回」自动生成。
|
||||
# 不加 is_reissue 布尔列 —— 「是不是补发」完全由来源是否存在决定,
|
||||
# 再加一列就是同一事实的两处存储,必然有不同步的一天。
|
||||
# 也不冗余存原出库单 ID:trans_return 已有 outbound_id,一跳即可。
|
||||
source_return_id = db.Column(db.Integer, index=True)
|
||||
|
||||
# 创建时间和更新时间
|
||||
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
|
||||
@ -91,6 +97,9 @@ class OutboundApproval(db.Model):
|
||||
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,
|
||||
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
|
||||
'reject_reason': self.reject_reason,
|
||||
# 补发标识:前端据此打「补发」标签
|
||||
'source_return_id': self.source_return_id,
|
||||
'is_reissue': self.source_return_id is not None,
|
||||
'items': self.get_items(),
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
|
||||
@ -132,6 +141,13 @@ class TransOutbound(db.Model):
|
||||
|
||||
# 签字与追溯
|
||||
consumer_name = db.Column(db.String(100)) # 领用人/客户
|
||||
# ★ 申请人ID:创建出库时从**关联审批单**带出(request_id 已强制必填,
|
||||
# approval 恒非 None)。为什么需要它:退回后勾选补发时,补发单要挂回
|
||||
# 「真正该拿东西的人」名下;而 consumer_name 是扫码时自由填写的领用人/
|
||||
# 客户名,既不可靠也可能不是本系统用户,按姓名反查会重名错绑。
|
||||
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与其来源审批单之间没有任何可用
|
||||
# 关联,无从回填;退回时由库管在选择器里明确指定。
|
||||
applicant_id = db.Column(db.Integer, index=True)
|
||||
signature_path = db.Column(db.Text) # 电子签名图片路径
|
||||
outbound_time = db.Column(db.DateTime, default=beijing_time)
|
||||
operator_name = db.Column(db.String(100)) # 操作员
|
||||
|
||||
@ -237,7 +237,11 @@ class OutboundService:
|
||||
'outbound_type': data.get('outbound_type', 'SALES'),
|
||||
'signature_path': data.get('signature_path'),
|
||||
'operator_name': operator_name,
|
||||
'remark': data.get('remark')
|
||||
'remark': data.get('remark'),
|
||||
# ★ 申请人从**关联审批单**带出。落这一列是为了让后续「退回 → 补发」
|
||||
# 能把补发单挂回真正该拿东西的人名下 —— consumer_name 是自由文本
|
||||
# (可能是客户名),不能作为依据。
|
||||
'applicant_id': approval.applicant_id,
|
||||
}
|
||||
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
@ -835,6 +839,10 @@ class OutboundService:
|
||||
# ★ 退回额度三件套:前端据此显示「已退 / 可退」并置灰已退满的行
|
||||
'returned_quantity': returned,
|
||||
'returnable_quantity': qty - returned,
|
||||
# ★ 原申请人ID:退回弹窗勾选「需要补发」时据此**精确预填**「补发给谁」。
|
||||
# 为 NULL 表示该出库产生于本列上线之前(历史无从回填),
|
||||
# 前端不预填,由库管在选择器里明确指定。
|
||||
'applicant_id': d.applicant_id,
|
||||
'unit_price': price,
|
||||
'subtotal': subtotal,
|
||||
'batch_sn': batch_sn,
|
||||
|
||||
20
inventory-web/src/api/common/users.ts
Normal file
20
inventory-web/src/api/common/users.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 在职人员名单(id + 姓名)—— 「选择某人」类下拉框的共用数据源。
|
||||
*
|
||||
* 使用场景:借出/转交/归还的借用人选择、出库退回补发的「补发给谁」等。
|
||||
*
|
||||
* ★ 为什么不复用 transaction.ts 里的 getBorrowUsers:
|
||||
* 那条路径(/transactions/borrow/users)在语义上属于借库模块,出库补发直接
|
||||
* 复用会让后人困惑「出库为什么在调借库的接口」。后端两者共用同一份实现,
|
||||
* 前端各走各的中性/专用入口。
|
||||
*
|
||||
* ★ 公司隔离与服务端同口径;只返回 id 与姓名,不含邮箱/角色/部门。
|
||||
*/
|
||||
export function getActiveUsers() {
|
||||
return request({
|
||||
url: '/v1/common/active-users',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@ -18,13 +18,27 @@ import request from '@/utils/request'
|
||||
* return_qty: number 必填,本次退回数量(>0)
|
||||
* is_defective: boolean 必填,true=不良品转在管台账,false=良品加回库存
|
||||
* reason: string 可选,退回原因
|
||||
* need_reissue: boolean 可选,退回后是否自动生成补发单(默认 false)
|
||||
* reissue_qty: number 可选,补发数量,默认 = return_qty
|
||||
* reissue_applicant_id: number 可选,**补发给谁**;不传则回退为当前操作人
|
||||
* }
|
||||
*
|
||||
* ★ 补发(need_reissue=true):后端直接生成一张**免审批**出库单并关联回本笔退回。
|
||||
* 申请人优先取 reissue_applicant_id(前端选择的「补发给谁」),未传则回退为
|
||||
* 当前操作人 —— trans_outbound 没有申请人字段,也无指回原审批单的关联,
|
||||
* 无法可靠推断原申请人(按姓名反查会重名错绑),故把选择权交给现场。
|
||||
* ★ 补发库存不足时**整笔退回一并回滚**并返回明确提示 —— 取消勾选即可只做退回过账。
|
||||
*
|
||||
* @returns data.reissue 为 { id, request_no, quantity } 或 null
|
||||
*/
|
||||
export function returnFromOutbound(data: {
|
||||
outbound_id: number
|
||||
return_qty: number
|
||||
is_defective: boolean
|
||||
reason?: string
|
||||
need_reissue?: boolean
|
||||
reissue_qty?: number
|
||||
reissue_applicant_id?: number
|
||||
}) {
|
||||
return request({
|
||||
url: '/inbound/stock/return-from-outbound',
|
||||
|
||||
@ -269,6 +269,48 @@
|
||||
placeholder="请填写退回原因(必填)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- ★ 补发:退回后申请人往往**仍然需要这件东西**(尤其坏件 ——
|
||||
原需求并未被满足)。勾选即自动生成一张免审批出库单并关联回本笔退回,
|
||||
不必再靠人记住、另建一张与原单毫无关系的单。 -->
|
||||
<el-form-item label="需要补发">
|
||||
<el-checkbox
|
||||
v-model="returnDialog.form.need_reissue"
|
||||
@change="handleNeedReissueChange"
|
||||
>退回后自动生成补发单</el-checkbox>
|
||||
<div style="color:#909399; font-size:12px; line-height:1.6; margin-top:4px;">
|
||||
勾选后系统直接生成一张<strong>免审批</strong>的出库单(原领用人写入备注),
|
||||
库管即可扫码补发。<br>
|
||||
若<strong>库存不足,整笔退回会一并取消</strong>并给出提示 ——
|
||||
取消勾选即可只做退回过账。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="补发数量" v-if="returnDialog.form.need_reissue" required>
|
||||
<el-input-number
|
||||
v-model="returnDialog.form.reissue_qty"
|
||||
:min="1"
|
||||
:max="normalizeQty(returnDialog.form.return_qty)"
|
||||
:step="1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="补发给谁" v-if="returnDialog.form.need_reissue" required>
|
||||
<el-select
|
||||
v-model="returnDialog.form.reissue_applicant_id"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择补发给谁"
|
||||
style="width: 260px"
|
||||
>
|
||||
<el-option v-for="u in reissueUsers" :key="u.id" :label="u.name" :value="u.id" />
|
||||
</el-select>
|
||||
<div style="color:#909399; font-size:12px; margin-top:4px;">
|
||||
补发单会挂在此人名下,出现在他的「我的申请」里。<br>
|
||||
<strong>必须选择</strong>:补发是原申请人的需求,不能挂到办理退回的库管名下。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
@ -287,6 +329,7 @@ import { ref, computed, onMounted, reactive, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getOutboundList } from '@/api/outbound'
|
||||
import { returnFromOutbound } from '@/api/inbound/return'
|
||||
import { getActiveUsers } from '@/api/common/users'
|
||||
import { formatQty, normalizeQty } from '@/utils/format'
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@ -501,9 +544,53 @@ const returnDialog = reactive({
|
||||
return_qty: 1,
|
||||
is_defective: false,
|
||||
reason: '',
|
||||
// 补发:默认**不勾选** —— 有些退回是项目结束退还,根本不需要补
|
||||
need_reissue: false,
|
||||
reissue_qty: 1,
|
||||
// 补发给谁:留空则由后端回退为当前操作人
|
||||
reissue_applicant_id: null as number | null,
|
||||
},
|
||||
})
|
||||
|
||||
// 补发人员名单:勾选补发时才加载(多数退回不需要补,避免无谓请求)
|
||||
const reissueUsers = ref<Array<{ id: number; name: string }>>([])
|
||||
let reissueUsersLoaded = false
|
||||
|
||||
const loadReissueUsers = async () => {
|
||||
if (reissueUsersLoaded) return
|
||||
try {
|
||||
const res: any = await getActiveUsers()
|
||||
reissueUsers.value = res?.data || []
|
||||
reissueUsersLoaded = true
|
||||
} catch {
|
||||
reissueUsers.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 勾选补发时:默认补发量 = 本次退回量,并预选「补发给谁」
|
||||
const handleNeedReissueChange = async (val: boolean) => {
|
||||
if (!val) return
|
||||
returnDialog.form.reissue_qty = normalizeQty(returnDialog.form.return_qty) || 1
|
||||
await loadReissueUsers()
|
||||
if (returnDialog.form.reissue_applicant_id) return
|
||||
|
||||
const row = returnDialog.row || {}
|
||||
// ① 优先用原出库明细记录的申请人(创建出库时从审批单带出,主键无歧义)
|
||||
if (row.applicant_id && reissueUsers.value.some((u) => u.id === row.applicant_id)) {
|
||||
returnDialog.form.reissue_applicant_id = row.applicant_id
|
||||
return
|
||||
}
|
||||
// ② 历史单据该字段为 NULL(无从回填):退而按原领用人姓名**唯一命中**才预填。
|
||||
// 重名一律留空让现场选 —— 与借用人回填同口径,宁可多一步也不猜错人
|
||||
// (补发单会挂到错的人名下,而那是别人的「我的申请」)。
|
||||
const want = String(row.consumer_name || '').split('/')[0].trim()
|
||||
if (!want) return
|
||||
const hits = reissueUsers.value.filter(
|
||||
(u) => String(u.name || '').split('/')[0].trim() === want,
|
||||
)
|
||||
if (hits.length === 1) returnDialog.form.reissue_applicant_id = hits[0].id
|
||||
}
|
||||
|
||||
const openReturnDialog = (row: any) => {
|
||||
returnDialog.row = row
|
||||
// 默认带入「本次可退最大」,业务上整行退回最常见;用户可再调小做部分退回
|
||||
@ -511,6 +598,9 @@ const openReturnDialog = (row: any) => {
|
||||
returnDialog.form.return_qty = normalizeQty(row?.returnable_quantity)
|
||||
returnDialog.form.is_defective = false
|
||||
returnDialog.form.reason = ''
|
||||
returnDialog.form.need_reissue = false
|
||||
returnDialog.form.reissue_qty = 1
|
||||
returnDialog.form.reissue_applicant_id = null
|
||||
returnDialog.visible = true
|
||||
}
|
||||
|
||||
@ -520,6 +610,9 @@ const resetReturnDialog = () => {
|
||||
returnDialog.form.return_qty = 1
|
||||
returnDialog.form.is_defective = false
|
||||
returnDialog.form.reason = ''
|
||||
returnDialog.form.need_reissue = false
|
||||
returnDialog.form.reissue_qty = 1
|
||||
returnDialog.form.reissue_applicant_id = null
|
||||
}
|
||||
|
||||
const submitReturn = async () => {
|
||||
@ -542,6 +635,25 @@ const submitReturn = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 补发的前置校验:与后端同口径,避免白跑一趟
|
||||
const needReissue = !!returnDialog.form.need_reissue
|
||||
const reissueQty = normalizeQty(returnDialog.form.reissue_qty)
|
||||
if (needReissue) {
|
||||
if (!reissueQty || reissueQty <= 0) {
|
||||
ElMessage.warning('请填写补发数量')
|
||||
return
|
||||
}
|
||||
if (reissueQty > qty) {
|
||||
ElMessage.warning(`补发数量不能超过本次退回数量 ${qty}`)
|
||||
return
|
||||
}
|
||||
// 申请人必须明确指定:补发是原申请人的需求,不能默认挂到办理退回的库管名下
|
||||
if (!returnDialog.form.reissue_applicant_id) {
|
||||
ElMessage.warning('请选择「补发给谁」')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
returnDialog.submitting = true
|
||||
try {
|
||||
const res = await returnFromOutbound({
|
||||
@ -549,6 +661,12 @@ const submitReturn = async () => {
|
||||
return_qty: qty,
|
||||
is_defective: returnDialog.form.is_defective,
|
||||
reason,
|
||||
need_reissue: needReissue,
|
||||
reissue_qty: needReissue ? reissueQty : undefined,
|
||||
// 留空则由后端回退为当前操作人
|
||||
reissue_applicant_id: needReissue
|
||||
? (returnDialog.form.reissue_applicant_id ?? undefined)
|
||||
: undefined,
|
||||
})
|
||||
ElMessage.success(res?.msg || '退回成功')
|
||||
returnDialog.visible = false
|
||||
|
||||
Reference in New Issue
Block a user