6 Commits

Author SHA1 Message Date
07567d2f76 fix(outbound): 「补发给谁」改为必选,并按原申请人精确预填
· 预填顺序改为:① 原出库明细记录的 applicant_id(创建出库时从审批单带出,
  主键无歧义)→ ② 历史单据该字段为 NULL 时,退而按原领用人姓名**唯一命中**
  预填;重名一律留空。
· 「补发给谁」改为**必选**:提交前校验,未选即拦下并提示。
  不再有「留空则挂当前操作人」的兜底 —— 那会把别人的需求记到库管名下。
· 文案同步:明确写「必须选择:补发是原申请人的需求,不能挂到办理退回的库管名下」。
2026-09-17 12:07:21 +08:00
0005a689dc fix(outbound): 补发申请人取原申请人,绝不再回退为库管
问题
----
上一版在库管未指定「补发给谁」时,把补发单申请人回退成了**当前操作人(库管)**。
但补发是**原申请人的需求**,挂到库管名下逻辑不通 —— 那张单会出现在库管的
「我的申请」里,而真正该拿东西的人什么也看不到。

根因
----
trans_outbound 只有 consumer_name(**扫码时前端自由填写**的领用人/客户名,
既不可靠也可能是外部客户),没有任何指回原审批单的关联,所以当时只能退而求其次。

根治
----
一、trans_outbound 新增 applicant_id,**创建出库时从关联审批单带出**
    (request_id 已强制必填、approval 恒非 None,故新单据必然有值)。
    落这一列后,「退回 → 补发」即可自动找回真正的原申请人。
    ⚠ 存量行为 NULL —— 存量出库与其来源审批单之间没有任何可用关联,无从回填。
      刻意留 NULL 而不是按姓名猜(consumer_name 是自由文本,会重名错绑),
      与 dispatch_operator 同一取舍:宁可留空,也不猜。

二、退回接口的申请人优先级改为:
      ① 前端显式指定 reissue_applicant_id
      ② 原出库明细记录的 applicant_id(真实原申请人)
      ③ 都没有 → **报错要求指定**
    ★ 彻底移除「回退为当前操作人」—— 那正是本次要修的逻辑错误。

三、出库列表明细返回 applicant_id,供前端精确预填。

验证(9 项断言全通过)
  · 新单据 → 申请人 = 原申请人(12),绝不是库管(7)
  · 显式指定优先于原申请人
  ★ 历史单据未指定 → 接口拒绝、要求选择「补发给谁」、整笔回滚、
    且**未生成任何挂在库管名下的补发单**
  · 历史单据 + 显式指定 → 正常
  库存与数据零残留。
2026-09-17 12:07:21 +08:00
05524c988a feat(outbound): 退回弹窗增加「补发给谁」选择器
· 勾选「需要补发」后出现「补发给谁」下拉(filterable + clearable),
  并自动带上补发数量。
· 预填策略:按原领用人 consumer_name **唯一命中**才预选;重名一律留空让现场
  自己选 —— 与借用人回填同口径,宁可多一步也不猜错人(补发单会挂到错的人名下)。
· 留空时后端回退为当前操作人,文案已写明。
· 人员名单**勾选补发时才加载**(多数退回不需要补,避免无谓请求),加载后缓存。
· 新增 api/common/users.ts 的 getActiveUsers():走中性的
  /v1/common/active-users,而不是复用借库专用路径。
2026-09-17 12:01:12 +08:00
27e5589a5e feat(outbound,common): 补发可指定「补发给谁」+ 抽出通用人员名单接口
一、补发申请人可选择(原单退回)
   退回接口新增 reissue_applicant_id:
     ① 前端指定 → 校验用户存在后落库;
     ② 未指定 → 回退为**当前操作人**(原行为不变,向后兼容)。
   为何不自动推断原申请人:trans_outbound **没有申请人字段,也没有指回原审批单
   的关联**(扫码出库时只把审批单状态置为 3),按 consumer_name 反查会重蹈
   「重名错绑」的覆辙(借用人姓名回填那轮刚踩过)。故把选择权交给现场,不猜。

二、抽出中性人员名单 GET /api/v1/common/active-users
   实现抽到 common.active_user_options(),借库的 /transactions/borrow/users
   改为调同一函数 —— 实现只有一份,但出库补发走**中性路径**,不再出现
   「出库为什么在调借库的接口」这种跨模块语义错位。
   仅要求登录、只返回 id 与姓名(与 /auth/users/approvers 同一处理)。

★ 本次无需 DB 迁移:未新增任何列,补发申请人是复用已有的
  outbound_approval.applicant_id。

验证(打桩/真实 token 直连接口,12 项断言全通过)
  · 名单只含 id/name,无邮箱/角色/部门;借库原路径返回值与新路径完全一致
  · 指定「补发给谁」→ 补发单申请人 = 指定的人;备注仍含原领用人
  · 不指定 → 回退为当前操作人
  ★ 指定不存在的用户 → 被拒,且整笔退回回滚(流水未落库)
  库存与数据零残留。
2026-09-17 12:01:11 +08:00
9d187593eb feat(outbound): 退回弹窗增加「需要补发」勾选与补发数量
· 退回对话框新增「退回后自动生成补发单」勾选框 + 补发数量(勾选时默认 = 本次
  退回量,可调小;上限即退回量)。
· 文案明写两条关键后果:生成的是**免审批**出库单;**库存不足时整笔退回会一并
  取消**,取消勾选即可只做退回过账 —— 避免库管对着失败提示发懵。
· 提交前做同口径前置校验(数量 > 0 且 <= 退回量),不白跑一趟。
· resetReturnDialog / openReturnDialog 同步重置这两个字段。

默认**不勾选**:有些退回是项目结束退还,根本不需要补,不该默认给所有人挂上。
2026-09-17 11:56:39 +08:00
7071c89305 feat(outbound): 原单退回后可自动生成补发单
背景
----
原单退回只做两件事:良品加回库存 / 不良品转在管台账。但**申请人的需求并没有
被满足** —— 东西交回来了(甚至还是坏的),系统却不提醒任何人、无单据承载
「我要重新领一份」,退回与后续再出库之间也毫无关联。现场只能靠人记住再手建
一张出库申请,而那张单与原单看不出任何关系。

改动
----
· outbound_approval 新增 source_return_id(非空 = 补发单),把「退回 → 补发」
  串成闭环。
· POST /inbound/stock/return-from-outbound 新增 need_reissue / reissue_qty:
  勾选即自动生成一张**免审批**出库单(status=1,直接进入待执行),
  沿用原单出库类型,并关联回本笔退回。

★ 为什么只存退回单 ID,不加 is_reissue 布尔列
  「是不是补发」完全由来源是否存在决定,再加一列就是同一事实的两处存储,
  必然有不同步的一天。也不冗余存原出库单 ID:trans_return 已有 outbound_id。

★ 库存不足 → 整笔回滚(关键取舍)
  补发走 reserve_for_items(strict=True),与出库申请同一口径。不足时抛错,
  退回也一并回滚 —— 若只让补发静默失败,「需要补发」的意图就丢了,
  那正是本功能要解决的问题。库管看到提示后取消勾选即可只做退回过账。

★ 一个被发现的数据约束(改变了原设计)
  原打算把补发单的申请人设为「原出库单的申请人」,但 **trans_outbound 既没有
  申请人字段,也没有指回原审批单的关联**(扫码出库时只把审批单状态置为 3)。
  按 consumer_name 反查会重蹈「重名错绑」的覆辙。故申请人取**当前操作人**,
  原领用人写入备注供人工追溯。
  ⚠ 若业务要求补发单挂在原领用人名下,需要前端在退回弹窗里加一个「补发给谁」
    的人员选择 —— 请确认是否需要。

验证(打桩 JWT 直连真实接口,22 项断言全通过)
  勾选补发 → 免审批单生成、关联退回、预占库存、原领用人入备注;
  不勾选 → 不生成补发单、良品正常回库;
  ★ 库存不足 → 接口拒绝且**退回流水/补发单/退回额度全部未落库**(整笔回滚);
  补发量 > 退回量被拒;库存与数据零残留。
2026-09-17 11:56:38 +08:00
11 changed files with 474 additions and 17 deletions

View 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;

View 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;

View File

@ -5,3 +5,4 @@ common_bp = Blueprint('common', __name__)
# 导入子模块,使其路由装饰器注册到 common_bp
from . import search
from . import users # noqa: F401 「选择某人」类下拉框的共用人员名单

View 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()})

View File

@ -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

View File

@ -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()})
# --- 发起借库转交(双向握手第一步)---

View File

@ -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)) # 操作员

View File

@ -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,

View 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'
})
}

View File

@ -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',

View File

@ -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