22 Commits

Author SHA1 Message Date
1ef9ae4ad9 feat(records): 报废记录接入高级筛选
复用 app/utils/advanced_filter.py,但报废有两处与出库/借还不同,需特殊处理:

一、scrap_request_no 可为 NULL(历史直接报废)
  出库/借还可直接对单号列做 IN,报废不行——「单号 IN」永远匹配不上 NULL 行,
  会把历史单整体漏掉。此处统一用「行级等价键」表达同单关系:
    有单号 → scrap_request_no 相等
    无单号 → 同一分钟(date_trunc) + 同一操作人(与 Python 侧分组逻辑完全一致)

二、物料名不能用单号传递结果
  物料名需三表联查,但联查结果若用「单号 IN」回接,同样会漏掉 NULL 单号行。
  改用 tuple_(source_table, stock_id).in_(pairs) 元组定位报废行。

三、否定操作符
  命中行 → 折算同单等价键谓词 → 否定时整体取反(~pred),实现整单排除。

前端 scrap/index.vue 新增「高级筛选」el-popover,与出库/借还版式一致。

验证(库中当前唯一报废单含 SKU 0000000272):
  sku contains 0000000272 → 1 单
  sku ne 0000000272       → 0 单(整单排除,符合预期)
  material_name not_contains 白板 → 0 单(该单物料名含白板,被排除)
  单号 ne(父级)          → 1 单(父级否定语义不变)
2026-09-10 13:06:07 +08:00
fd0bfd3d9c feat(records): 借还记录接入高级筛选
复用 app/utils/advanced_filter.py 的解析与谓词逻辑:
  · 父级字段(单号 borrow_no、借用人 borrower_name)走标准 SQL 谓词
  · 子级字段(SKU、物料名称)经单号子查询过滤,否定操作符走 NOT IN 整单排除
  · 物料名经三表联查(buy/semi/product JOIN material_base)

借还的单号维度查询基于 order_subq 子查询分页,故此处把过滤条件施加在
order_subq.c.borrow_no 上,与既有的状态/日期/公司隔离过滤保持同一层次。

前端 records.vue 新增「高级筛选」el-popover(字段/操作符/值 + 添加条件/
应用筛选/重置),序列化为 advancedFilters JSON 字符串随查询下发。

验证:sku ne 0000000002 → 52 单(库中无单含该 SKU,正确不减);
      sku contains 0000 → 52 单(52 单的 SKU 全部含 0000,数据巧合)。
2026-09-10 13:06:03 +08:00
e437b3cece feat(records): 高级筛选引擎 + 出库记录接入
一、新增共享工具 app/utils/advanced_filter.py
  系统内已有该模式(material/list.vue、stock/inbound/buy.vue),
  沿用其既有约定:参数名 advancedFilters、值为 JSON 字符串、
  操作符 eq/ne/contains/not_contains/ge/le。

  · parse_advanced_filters() 解析并规整,坏输入退化为空列表不影响主查询
  · build_predicate() 单条件 → SQLAlchemy 谓词,未登记字段返回 None 杜绝列注入
  · build_material_name_select() 物料名三表联查(buy/semi/product JOIN material_base)

二、★ 父子关系处理(本次核心)
  记录接口返回的是**按单号分组的订单**,而用户筛选字段多落在**明细行**上。
  若直接 .filter(TransOutbound.sku.ilike(...)),会在 GROUP BY 前收窄明细范围,
  展开行里的兄弟明细会凭空消失。正确做法是先求「含匹配明细的单号集合」
  再让主查询按单号 IN 过滤。

  实测对照(单 OUT-20260811-1519-0003,21 条明细):
    按其中一条 SKU 筛选 → 子查询法保住全部 21 条;直接 filter 只剩 1 条。

三、★ 否定操作符语义(NOT IN)
  子级字段的 ne / not_contains 不能直接用 SQL != / NOT LIKE —— 那表达的是
  「本单存在某条不等于 X 的明细」,多明细单几乎必然成立,等于筛选失效。
  用户意图是**整单排除**,故 apply_child_condition() 统一:
    肯定 → order_no     IN (含匹配明细的单号)
    否定 → order_no NOT IN (含匹配明细的单号)
  两者子查询完全一致(都用肯定形式谓词),仅外层取反。
  父级字段(单号/操作人)仍走标准 SQL 谓词,语义无歧义。

四、出库记录接入(前端弹窗 + 后端接线)

验证:
  eq 0000000002 → 1 单;material_name contains 白板 → 16 单
  sku ne 0000000002 → 394 = 395-1,含该 SKU 的单被整体排除
  material_name not_contains 白板 → 379 = 395-16
2026-09-10 13:05:59 +08:00
c35ec9a659 feat(records): 出库/借还记录统一高级搜索版式
三个记录页(出库/借还/报废)此前搜索区形态各异:出库用裸 div + 分散样式,
借还缺日期范围,报废只有 SKU 输入框。现统一为 el-form :inline="true" +
.filter-form 版式,并补齐缺失的过滤维度。

借还记录(新增日期范围能力):
  · 后端 get_records 增加 start_date/end_date 参数,按「借出时间」过滤;
  · 边界补全时分秒(YYYY-MM-DD → 当日 00:00:00 / 23:59:59),
    与出库记录同口径,解决零点截断导致当天记录漏查的问题;
  · API 层透传两个新参数。

出库记录:
  · 版式统一为 inline 表单,日期选择器加 label;
  · 新增「重置」按钮;
  · 搜索类型切换由 500ms 防抖改为立即查询。

借还记录:
  · 新增「重置」按钮;搜索类型/状态切换改为立即查询(取消防抖)。

实测(SUPER_ADMIN):
  报废 全部/单号/SKU/操作人/物料名/日期 六类过滤均 200 且命中正确;
  借还 无过滤 52 单 / 本月 18 单 / 空区间 0 单(日期过滤生效);
  出库 无过滤 395 单 / SKU 277 / 姓名 7 / 物料名 16 / 单号 OUT-2026 命中 395。
2026-09-10 12:06:08 +08:00
23a7fc65f1 feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索
一、后端按「订单」分组(原为平铺明细列表)
  有 scrap_request_no → 按单号分组;
  无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组,
  生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。
  每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。

二、物料解析改为批量预加载
  原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、
  trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id)
  收集 ID → 批量 joinedload 查询 → 内存拼装。

三、损失金额改为按权限可见
  原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色
  也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的
  filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。

四、新增 keyword / search_type 高级搜索参数
  (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤)

五、修复申请人显示
  ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经
  _display_name() 去掉 '/账号' 后缀。

前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式,
搜索区改用统一 el-form inline 版式(含重置按钮)。

实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/
虚拟单号均正确。
2026-09-10 12:06:04 +08:00
3cadd42338 refactor(scrap): 报废管理子菜单按业务流排序
顺序调整为:报废申请 → 按单报废 → 报废记录 → 报废审批。

侧边栏由 layout/components/Sidebar/index.vue 的 v-for="child in
route.children" 直接按路由数组顺序渲染,无排序逻辑,故仅需调整数组元素。
原「按单报废执行」标题缩短为「按单报废」(四字,与同级菜单一致)。

注:父菜单 redirect 仍指向 /scrap/index(报废记录),与出库模块
redirect 指向「出库记录」的既有惯例保持一致,未改动。
2026-09-10 11:33:01 +08:00
1aa31334b1 fix(scrap): 报废申请页搜索/选择解耦,审批人常驻必填
一、搜索丢失已选(核心 bug)
主表格 :data="stockList" 同时充当搜索结果与选择容器。Element Plus 的
setData 在 reserveSelection 为假时走 clearSelection(),进而 emit
selection-change([]),故每次重新搜索都会清空选中态、「已选 N 条」归零。
(源码路径:table/src/store/index.mjs:34-52 → store/watcher.mjs:145-152)

改为「购物车 + 选择弹窗」结构,对齐出库选单页:
  · 主表格数据源改为 cart(已选清单),删除复选列,改「移除」按钮;
  · 新增「选择库存物品」弹窗,搜索框移入其中;
  · 表格 row-key + :reserve-selection="true",跨搜索/翻页保留勾选;
  · 点行勾选、改数量自动勾选、数字框 @click.stop 防冒泡反转勾选;
  · 服务端搜索(350ms 防抖)+ 分页 20/50/100/200;
  · uniqueKey 取 source_table_id(三表主键各自独立,必须带表名前缀)。

二、审批人字段不显示
原为 v-if="approvalVisible",仅库管代建或命中需审批物料时才渲染。
因全库仅 1 个物料标记 is_approval_required,该条件几乎从不成立。
现改为常驻,并用 el-form rules 声明 approver_id / remark 双必填;
打开弹窗即调用 checkScrapApproval 预检并展示提示文案。

三、顺带修复
库位列此前恒为空:三张库存表 to_dict 只输出 warehouse_loc,而模板绑定
warehouse_location。已在 loadStockList 中归一化。

SFC 编译与 vue-tsc 通过,无新增类型错误。
2026-09-10 11:32:56 +08:00
e152f16ebc feat(scrap): 报废一律需审批,不再按物料标记区分
业务规则变更:所有报废申请都必须由指定审批人审批通过后才能执行。

原逻辑走 resolve_approval_control 判定是否需审批,而 material_base 表中
仅 1/3012 个物料标记了 is_approval_required,意味着 99.97% 的报废申请会
走免审批分支——status 直接置 1、actual_approver_id 被赋为申请人自己、
审批人参数被静默丢弃。前端即便做了必填也只是摆设。

改动(规则收敛到单一来源):
  · 新增 SCRAP_ALWAYS_REQUIRES_APPROVAL = True,作为唯一开关;
  · submit_approval() 无审批人一律拒绝;恒置 status=0(待审批);
    删除免审批自动通过分支;
  · resolve_approval_control 仍调用,但仅用于生成提示文案,
    不再参与是否审批的判定;
  · /request/check-approval 返回 need_approval=SCRAP_ALWAYS_REQUIRES_APPROVAL,
    否则该接口会继续返回 false,导致前端预检结果失真。

实测:不传审批人 → 拒绝;传审批人 → status=0 且 actual_approver_id 为空。
⚠ 注意:此前自动通过的单据今后一律进入审批队列。
2026-09-10 11:32:51 +08:00
f589962e5c feat(scrap): 新增报废专用库存查询端点,解耦出库选单权限
报废申请页原复用 /inbound/stock/list,该端点权限是 outbound_selection。
持有 scrap_apply 的角色目前恰好也都持有该权限,故能跑通,但属隐性耦合:
任一角色授权脱钩就会静默 403,被前端 catch 掩盖成「加载库存失败」。

照抄借库既有先例(transactions.py 的 /borrow/stock-list),新增:
  GET /v1/scrap/stock-list  @permission_required('scrap_apply')

★ 同步把 'scrap_apply' 登记进 stock.py 的 SELECTION_PREFIXES。
  _make_price_stripper 仅当前缀为 None 或已在集合中时才剥离价格,
  漏登记会导致价格/成本字段泄露给报废申请人(Fail-Closed 失效)。

实测:OUTBOUND/WAREHOUSE_MGR 均 200,无 token 401,
响应中 unit_price/total_price/sale_price 等价格字段零泄漏。
2026-09-10 11:32:44 +08:00
f556514e72 refactor(scrap): 扫码校验改用 SKU 主键,与后端同口径
前端 validateAgainstPlan 由 (source_table, stock_id) 改为 SKU 主键:
  · normSku() 去首尾空白,与后端 _norm_sku 一致;
  · matchKey() 与后端 _match_key 完全同构(sku: 前缀 / row: 回退);
  · approvedQtyByKey() 聚合同一 SKU 的批准总量,兼容批准单同 SKU 多行;
  · 扫码不在批准明细内、或该 SKU 累计量超批准量时,红色 toast + 震动阻断,
    不入购物车;
  · 购物车去重与 maxScrapFor 上限同步改为按 SKU 计算。

已通过 SFC 编译与 vue-tsc(无新增类型错误)。
2026-09-10 10:21:53 +08:00
ed351f96ec refactor(scrap): 按单执行校验改用 SKU 主键匹配
架构要求以 SKU 为唯一校验键,原实现按 (source_table, stock_id) 匹配。

已核验数据前提:SKU 在同一库存表内唯一(0 重复),且不存在跨表重名
(stock_buy / stock_semi / stock_product 交叉 0 冲突),故 SKU 可安全
作为跨来源标识。

改动:
  · 新增 _match_key():SKU 非空时以 'sku:<SKU>' 为键;个别历史库存行
    SKU 为空,回退 'row:<table>#<id>',前缀区分确保绝不串键;
  · _build_approved_index() 按 SKU 聚合,累加批准量并保留各行的
    source_table + stock_id(rows 列表);
  · 校验拆为两步并给出明确文案:SKU 是否在批准明细内、同 SKU 累计量
    是否超批准量;
  · ★ 扣减改为按批准单配对的 source_table + stock_id 定位库存行加锁,
    不再信任前端传来的 stock_id —— 实测前端传伪造 stock_id=99999 时,
    仍从批准单指定的库存行扣减,台账同样记录批准单的 stock_id;
  · 同一 SKU 在批准单占多行时,按批准顺序依次分配扣减量。

实测 7 项校验用例 + 多行分配 / 批准行已删除 / 可用不足 均符合预期。
2026-09-10 10:21:50 +08:00
af2d1c10c6 feat(scrap): 报废作业页重构为「按单扫码执行」,对齐出库版式
create.vue 由「直接报废」页(492 行)重写为按单执行页:
  · 顶部选择已批准申请单(scope=executable, status=1);
  · 载入 items_json 为待执行清单,逐项显示批准数量与已扫数量;
  · 扫码头按 (source_table, stock_id) 匹配批准明细,
    不在单内或超批准量时红色 toast + 震动阻断,禁止入车;
  · 支持 ?requestId= 深链直达;提交实扫 payload 到 execute 接口。

审批页的「执行报废」由弹窗盲执行改为跳转本页(router.push + query.requestId),
移除 executeVisible/confirmExecute 等已失效代码;路由标题改为「按单报废执行」。
executeScrapByRequest 增加 items 参数。
2026-09-10 10:14:43 +08:00
7719943779 feat(scrap): 报废执行改为按单扫码校验,废弃盲执行
execute() 原先只吃 request_id,按申请单快照全量扣减,用户反馈「扫码与报废脱节」。
现改为接收实扫明细 scanned_items:

  · 强制按单:未提交实扫明细一律拒绝执行;
  · 键为 (source_table, stock_id),与申请单 items_json 同口径,
    同一物品多次扫码自动累加,兼容「逐件扫」与「输数量」两种操作;
  · 校验实扫项必须落在批准明细内,且累计量不得超批准量,否则整单拒绝;
  · 允许合法子集(少扫 = 本次不报废该行);
  · 扣减以实扫量为准,同时扣 available_quantity 与 stock_quantity
    (报废即实物销毁,上一版只扣可用库存会让总库存虚挂)。

接口 POST /scrap/request/<id>/execute 由无 body 改为接收 {items:[...]}。
2026-09-10 10:14:40 +08:00
b4b79c68a9 fix(borrow): 借库转报废实物不足时报错回滚,不再夹断到 0
原逻辑在 stock_quantity 减为负数时直接置 0。但借出时已扣减过
available_quantity,夹断会使 available_quantity > stock_quantity,
凭空多出可用库存,后续出库/借库可继续占用这批并不存在的实物。

改为不足即抛 ValueError,与同一事务内其余校验一致,整单回滚。
2026-09-10 10:14:36 +08:00
8755837fe8 fix(timezone): 审批单据时间统一为 naive 北京时间,消除 8 小时偏差
成因:approved_at / executed_at 列是 timestamp without time zone,而赋值用了
带时区的 datetime.now(timezone(timedelta(hours=8)))。psycopg2 对 naive 列不会
剥掉 tzinfo,而是转成 naive UTC 再写入,结果比同为北京时间的 created_at 早 8 小时。
(代码里并无 datetime.utcnow(),真实成因是 aware 值被驱动的隐式 UTC 转换。)

改动:
  · outbound_service / borrow_service 的审批分支
    datetime.now(beijing_tz).replace(tzinfo=None) → beijing_time()
    (免审批分支已在上一轮改为 beijing_time,此处补齐审批分支);
  · purchase_service 审批/完结分支同源缺陷一并修复;
  · scrap_approval / scrap_approval_service 的 _beijing()、_beijing_now() 由
    tz-aware 改为 naive —— 配合上述迁移把列类型改为 naive,
    若仍返回 aware 值,timestamptz→timestamp 后会反向早 8 小时。

实测四表(scrap/outbound/borrow/purchase)created_at 与 approved_at 差值
均在同一秒内(-1ms ~ -12ms)。
2026-09-10 10:14:31 +08:00
2045a89330 feat(db): 审批单据时间口径统一 + 补注册 scrap_selection 权限
unify_approval_timezone.sql:
  · scrap_approval 的 4 个时间列由 timestamptz 改为 timestamp without time zone,
    与 outbound/borrow 对齐(该表当时无业务数据,转换零损失);
  · 三张审批表 created_at/updated_at 的 DB 默认值由 UTC 的 now()/CURRENT_TIMESTAMP
    统一为 timezone('Asia/Shanghai', now()),杜绝绕过 ORM 的写入偏 8 小时。

add_scrap_selection_perm.sql:
  GET /scrap/scan 一直声明需要 scrap_selection,但该权限码从未写入
  sys_element / sys_role_permission,精确匹配与前缀展开匹配全部落空,
  导致除 SUPER_ADMIN 外没有任何角色能调用扫码接口。现补注册,
  并授予所有已持有 scrap_execute(按单报废执行)的角色。
2026-09-10 10:14:19 +08:00
8b61cdb67d fix(approval): 需审批判定改为 base_id → SKU → 名称+规格 三级降级
原判定仅有 base_id 与「名称+规格字符串」两级,出库申请明细只有 name/spec、
无 base_id,只能走脆弱的字符串匹配,同名或同规格不同料极易误判。

新增 SKU 优先级:material_base 表本身没有 sku 列,SKU 只存在于
stock_buy / stock_semi / stock_product,故经明细自带 source_table 反查
sku → base_id 再回查物料,查不到才依次降级其余两张库存表。
查到物料即定论,不再往下走字符串兜底,杜绝误杀。
2026-09-10 10:14:17 +08:00
98e657e289 fix(scrap): TransScrap 补 scrap_request_no 列映射
报废执行写台账时以关键字传入 scrap_request_no,但该列只存在于 DB
(db_migrations/add_scrap_approval.sql)而未在 ORM 声明,执行报废必然抛
TypeError: 'scrap_request_no' is an invalid keyword argument for TransScrap,
被 500 捕获后连同库存扣减一起回滚,整条按单报废链路不可用。
2026-09-10 10:14:14 +08:00
1dbf74b7bc feat(borrow): 借库审批补「完结」能力(对齐出库 status=4)
- mark_completed 改为 1→4 已完结(真正扫码借出完成仍为 status=3)
- 前端审批页新增「已完结」筛选项与状态映射;审批信息列对 3/4 显示审批人
2026-09-10 09:47:07 +08:00
da5357acb3 feat(scrap): 前端报废申请 / 报废审批页 + API(对齐出库版式)
- api/scrap.ts 新增 submitScrapRequest/checkScrapApproval/getScrapApprovals/approveScrapRequest/executeScrapByRequest
- apply: 选库存(带 source_table+stock_id)/报废数量/原因/按需审批人提交
- approval: 顶部审批状态筛选+刷新、展开明细、列名对齐出库、分页含 sizes、按单执行弹窗
- 路由挂载 /scrap/apply、/scrap/approval
2026-09-10 09:47:02 +08:00
ffbb2199f0 feat(scrap): 报废申请审批流 + 按单报废(后端)
- ScrapApproval 模型 + ScrapApprovalService:提交/列表/审批/执行(锁库存扣 available_quantity 写 TransScrap)
- 新路由 POST /scrap/request、/request/check-approval、GET /request、PATCH /request/<id>/approve、POST /request/<id>/execute;权限码 scrap_apply/scrap_approval/scrap_execute(无角色硬编码)
- 旧 /scrap 直接报废入口保持不变
- /inbound/stock/list 每项返回 source_table,供按单流程精准选库存
2026-09-10 09:46:57 +08:00
3d8fb198c5 feat(scrap): 报废审批流 DB 迁移(建表 + 权限码)
- scrap_approval 表(申请单,items_json 携带 source_table+stock_id 精准实物)
- trans_scrap 增加 scrap_request_no 关联申请单
- 注册权限码 scrap_apply / scrap_approval / scrap_execute 并按现有报废角色授予
2026-09-10 09:46:52 +08:00
26 changed files with 3737 additions and 545 deletions

View File

@ -0,0 +1,31 @@
-- =============================================================================
-- 报废审批流 + 按单报废(镜像 借库/出库 审批框架)
-- 1) 新表 scrap_approval(申请单)
-- 2) trans_scrap 增加 scrap_request_no,关联申请单号
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
CREATE TABLE IF NOT EXISTS scrap_approval (
id SERIAL PRIMARY KEY,
request_no varchar(100) UNIQUE NOT NULL,
applicant_id integer NOT NULL,
remark text,
status integer NOT NULL DEFAULT 0, -- 0待审 1已通过(待执行) 2驳回 3已执行
allowed_approvers text,
actual_approver_id integer,
approved_at timestamptz,
reject_reason text,
items_json text NOT NULL,
executed_at timestamptz,
executor_name varchar(100),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_scrap_approval_status ON scrap_approval(status);
CREATE INDEX IF NOT EXISTS ix_scrap_approval_applicant ON scrap_approval(applicant_id);
ALTER TABLE trans_scrap ADD COLUMN IF NOT EXISTS scrap_request_no varchar(100);
CREATE INDEX IF NOT EXISTS ix_trans_scrap_req_no ON trans_scrap(scrap_request_no);
COMMIT;

View File

@ -0,0 +1,57 @@
-- =============================================================================
-- 报废审批流权限码(动态权限,不硬编码角色)
-- scrap_apply 提交报废申请 / 预检
-- scrap_approval 查看审批列表 / 审批(通过/驳回)
-- scrap_execute 按单报废执行(扣减库存)
-- 授予:apply/execute 复制现有持 'scrap_create:operation' 的角色;
-- approval 复制现有持 'scrap_list:operation' 的角色 + 超管/主管。
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
-- 1) 注册权限元素
INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'scrap_create', '报废申请(提交)', 'scrap_apply', 'operation'
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'scrap_apply');
INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'scrap_create', '按单报废(执行)', 'scrap_execute', 'operation'
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'scrap_execute');
INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'scrap_list', '报废审批(查看/审批)', 'scrap_approval', 'operation'
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'scrap_approval');
-- 2) scrap_apply / scrap_execute ← 现有持 scrap_create:operation 的角色
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
SELECT DISTINCT sp.role_code, v.target_code, 'element', sp.company_name
FROM sys_role_permission sp
CROSS JOIN (VALUES ('scrap_apply'), ('scrap_execute')) AS v(target_code)
WHERE sp.target_code = 'scrap_create:operation' AND sp.type = 'element'
AND NOT EXISTS (
SELECT 1 FROM sys_role_permission r
WHERE r.role_code = sp.role_code AND r.target_code = v.target_code
AND r.type = 'element' AND COALESCE(r.company_name,'') = COALESCE(sp.company_name,'')
);
-- 3) scrap_approval ← 现有持 scrap_list:operation 的角色
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
SELECT DISTINCT sp.role_code, 'scrap_approval', 'element', sp.company_name
FROM sys_role_permission sp
WHERE sp.target_code = 'scrap_list:operation' AND sp.type = 'element'
AND NOT EXISTS (
SELECT 1 FROM sys_role_permission r
WHERE r.role_code = sp.role_code AND r.target_code = 'scrap_approval'
AND r.type = 'element' AND COALESCE(r.company_name,'') = COALESCE(sp.company_name,'')
);
-- 4) 超管 / 主管 兜底持有审批权
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
SELECT v.role_code, 'scrap_approval', 'element', NULL
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR')) AS v(role_code)
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_permission r
WHERE r.role_code = v.role_code AND r.target_code = 'scrap_approval' AND r.type = 'element'
);
COMMIT;

View File

@ -0,0 +1,34 @@
-- =============================================================================
-- 补注册 scrap_selection 权限码
--
-- 背景:
-- GET /api/v1/scrap/scan(按单报废执行页的扫码查库存接口)自始就声明需要
-- scrap_selection,但该权限码从未写入 sys_element / sys_role_permission。
-- 结果 permission_required('scrap_selection') 的精确匹配与展开匹配(前缀
-- scrap_selection:)全部落空,导致除 SUPER_ADMIN 外没有任何角色能调用扫码接口。
--
-- 处理:
-- 1) 注册 scrap_selection 权限元素(挂在 scrap_create 菜单下,与 scrap_apply /
-- scrap_execute 同级);
-- 2) 授予所有已持有 scrap_execute(按单报废执行)的角色,
-- 保证「执行报废」的人必然可以扫码。
--
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'scrap_create', '报废扫码(查询库存)', 'scrap_selection', 'operation'
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'scrap_selection');
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
SELECT DISTINCT sp.role_code, 'scrap_selection', 'element', sp.company_name
FROM sys_role_permission sp
WHERE sp.target_code = 'scrap_execute' AND sp.type = 'element'
AND NOT EXISTS (
SELECT 1 FROM sys_role_permission r
WHERE r.role_code = sp.role_code AND r.target_code = 'scrap_selection'
AND r.type = 'element' AND COALESCE(r.company_name,'') = COALESCE(sp.company_name,'')
);
COMMIT;

View File

@ -0,0 +1,43 @@
-- =============================================================================
-- 审批类单据时间口径统一(全部落到 naive 北京时间)
--
-- 背景:
-- 1) 容器内 PostgreSQL 时区为 Etc/UTC,库级默认 now() / CURRENT_TIMESTAMP 均为 UTC;
-- 2) scrap_approval 原先用 timestamptz 列 + tz-aware Python 值,而
-- outbound_approval / borrow_approval 用 timestamp without time zone 列;
-- 三张表口径不一致,且 outbound/borrow 的 DB 默认值 CURRENT_TIMESTAMP 是 UTC,
-- 一旦有绕过 ORM 的写入就会产生 8 小时偏差。
--
-- 目标:
-- · 三张审批单表的 created_at / updated_at 统一为 timestamp without time zone;
-- · DB 侧默认值统一为 timezone('Asia/Shanghai', now()),即北京时间;
-- · 应用层(SQLAlchemy 模型)始终显式传 beijing_time(),不依赖 DB 默认值。
--
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
-- -----------------------------------------------------------------------------
-- 1) scrap_approval:timestamptz -> timestamp without time zone
-- (该表此前无业务数据,转换不会造成既有时间语义漂移;
-- ALTER 后原 timestamptz 值会按会话时区 UTC 取钟面值,如需保留请先备份)
-- -----------------------------------------------------------------------------
ALTER TABLE scrap_approval
ALTER COLUMN created_at TYPE timestamp without time zone,
ALTER COLUMN updated_at TYPE timestamp without time zone,
ALTER COLUMN approved_at TYPE timestamp without time zone,
ALTER COLUMN executed_at TYPE timestamp without time zone;
ALTER TABLE scrap_approval ALTER COLUMN created_at SET DEFAULT timezone('Asia/Shanghai', now());
ALTER TABLE scrap_approval ALTER COLUMN updated_at SET DEFAULT timezone('Asia/Shanghai', now());
-- -----------------------------------------------------------------------------
-- 2) outbound_approval / borrow_approval:DB 默认值由 UTC 改为北京时间
-- 列类型本就是 naive,无需转换
-- -----------------------------------------------------------------------------
ALTER TABLE outbound_approval ALTER COLUMN created_at SET DEFAULT timezone('Asia/Shanghai', now());
ALTER TABLE outbound_approval ALTER COLUMN updated_at SET DEFAULT timezone('Asia/Shanghai', now());
ALTER TABLE borrow_approval ALTER COLUMN created_at SET DEFAULT timezone('Asia/Shanghai', now());
ALTER TABLE borrow_approval ALTER COLUMN updated_at SET DEFAULT timezone('Asia/Shanghai', now());
COMMIT;

View File

@ -252,7 +252,8 @@ def _make_price_stripper(permission_prefix=None):
仅当 permission_prefix 不在已知选单前缀列表中时放行(例如 material_list 场景由调用方自行处理)。
"""
# 已知的选单/借用前缀 → 无条件剥离所有价格成本
SELECTION_PREFIXES = {'outbound_selection', 'op_borrow_apply', 'inventory_stocktake'}
# ★ 新增前缀必须登记在此,否则该前缀的调用方会拿到价格/成本字段(Fail-Closed 失效)
SELECTION_PREFIXES = {'outbound_selection', 'op_borrow_apply', 'inventory_stocktake', 'scrap_apply'}
def stripper(d, stock_category):
if permission_prefix is None or permission_prefix in SELECTION_PREFIXES:
@ -315,6 +316,7 @@ def _do_get_stock_list(permission_prefix=None):
rows = q.all()
for item in rows:
d = item.to_dict()
d['source_table'] = 'stock_buy' # ★ 报废等按单流程需要精准定位来源表
d['stock_type'] = 'material'
d['type'] = 'material'
d['typeLabel'] = '采购件'
@ -341,6 +343,7 @@ def _do_get_stock_list(permission_prefix=None):
rows = q.all()
for item in rows:
d = item.to_dict()
d['source_table'] = 'stock_semi' # ★ 精准来源表
d['stock_type'] = 'semi'
d['type'] = 'semi'
d['typeLabel'] = '半成品'
@ -370,6 +373,7 @@ def _do_get_stock_list(permission_prefix=None):
rows = q.all()
for item in rows:
d = item.to_dict()
d['source_table'] = 'stock_product' # ★ 精准来源表
d['stock_type'] = 'product'
d['type'] = 'product'
d['typeLabel'] = '成品'

View File

@ -175,6 +175,12 @@ def get_outbound_list():
search_type = request.args.get('search_type', 'all')
company = request.args.get('company', '')
# ★ 高级筛选:JSON 字符串 → 条件列表(解析失败退化为空,不影响主查询)
from app.utils.advanced_filter import parse_advanced_filters
advanced_filters = parse_advanced_filters(
request.args.get('advancedFilters', '')
)
# ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;管理者看全部
consumer_name = None
if not is_privileged_viewer():
@ -189,7 +195,8 @@ def get_outbound_list():
# ★ [修改] 调用分组查询服务,支持搜索类型
result = OutboundService.get_grouped_list(
page, limit, keyword, search_type=search_type,
company=company, consumer_name=consumer_name
company=company, consumer_name=consumer_name,
advanced_filters=advanced_filters,
)
# 字段级脱敏

View File

@ -16,6 +16,29 @@ import math
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
# ==============================================================================
# 报废记录字段级权限过滤(对齐 outbound 的 filter_item_by_permissions)
#
# 原先 /records 无条件剥离 cost_at_scrap / total_loss,导致即使角色持有
# scrap_list:loss_amount 也看不到金额——与「损失金额」权限元素的存在相矛盾。
# 改为按权限决定可见性,与出库记录保持一致。
# ==============================================================================
def filter_scrap_by_permissions(item, user_permissions):
field_to_perm = {
'total_loss': 'scrap_list:loss_amount',
'loss_amount': 'scrap_list:loss_amount',
}
if 'scrap_list:*' in user_permissions:
return item
for field, perm_code in field_to_perm.items():
if field in item and perm_code not in user_permissions:
item[field] = None
for sub in (item.get('items') or []):
if isinstance(sub, dict):
filter_scrap_by_permissions(sub, user_permissions)
return item
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
@ -104,6 +127,24 @@ def create_scrap():
return jsonify({'code': 400, 'msg': str(e)}), 400
# --------------------------------------------------------
# 2.1 报废申请专用库存列表(独立权限,解耦出库选单)
# GET /api/v1/scrap/stock-list
# --------------------------------------------------------
@scrap_bp.route('/stock-list', methods=['GET'])
@jwt_required()
@permission_required('scrap_apply')
def get_scrap_stock_list():
"""
报废申请专用库存列表 — Fail-Closed: 剥离价格字段
与借库 /transactions/borrow/stock-list 同构:复用 _do_get_stock_list,
但挂 scrap_apply 权限,报废申请人无需持有出库选单权限。
"""
from app.api.v1.inbound.stock import _do_get_stock_list
return _do_get_stock_list(permission_prefix='scrap_apply')
# --------------------------------------------------------
# 3. 报废记录查询接口
# GET /api/v1/scrap/records
@ -117,6 +158,12 @@ def get_scrap_records():
sku = request.args.get('sku', '')
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
# ★ 高级筛选:JSON 字符串 → 条件列表
from app.utils.advanced_filter import parse_advanced_filters
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
try:
result = ScrapService.query_records(
@ -124,12 +171,17 @@ def get_scrap_records():
page_size=page_size,
sku=sku,
start_date=start_date,
end_date=end_date
end_date=end_date,
keyword=keyword,
search_type=search_type,
advanced_filters=advanced_filters,
)
# ★ Fail-Closed: 报废记录剥离成本字段
for item in (result.get('list') or []):
for k in ('cost_at_scrap', 'total_loss'):
item.pop(k, None)
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
perms = get_current_user_permissions()
if 'scrap_list:*' not in perms:
for item in (result.get('list') or []):
filter_scrap_by_permissions(item, perms)
return jsonify({'code': 200, 'msg': 'success', 'data': result})
except Exception as e:
traceback.print_exc()
@ -315,9 +367,97 @@ class ScrapService:
db.session.commit()
return {'count': len(created_records)}
# ------------------------------------------------------------------
# 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名)
# ------------------------------------------------------------------
@staticmethod
def query_records(page=1, page_size=50, sku='', start_date='', end_date=''):
"""分页查询报废记录"""
def _display_name(raw):
if raw is None or raw == '':
return ''
s = str(raw)
if s.isdigit():
u = SysUser.query.get(int(s))
if u and u.username:
s = u.username
return s.split('/')[0] if '/' in s else s
# ------------------------------------------------------------------
# 辅助:批量解析报废行的物料名称/规格/库位/批号(多态 5 条来源路径)
# ------------------------------------------------------------------
@staticmethod
def _resolve_materials(rows):
from app.models.transaction import TransBorrow
from sqlalchemy.orm import joinedload
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct,
}
resolved = {}
def _from_stock(stock_row):
if not stock_row:
return None
base = getattr(stock_row, 'base', None)
return {
'material_name': (base.name if base else '') or '',
'spec_model': (base.spec_model if base else '') or '',
'warehouse_location': getattr(stock_row, 'warehouse_location', '') or '',
'batch_number': (getattr(stock_row, 'batch_number', '')
or getattr(stock_row, 'serial_number', '') or ''),
}
# 1) 常规库存三表:批量拉取并 joinedload 物料主表
for table, model in model_map.items():
ids = {r.stock_id for r in rows if r.source_table == table and r.stock_id}
if not ids:
continue
for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all():
resolved[(table, obj.id)] = _from_stock(obj)
# 2) 借库转报废:stock_id 指向 trans_borrow.id,再经其 source_table 找库存
borrow_ids = {r.stock_id for r in rows if r.source_table == 'trans_borrow' and r.stock_id}
if borrow_ids:
borrows = TransBorrow.query.filter(TransBorrow.id.in_(borrow_ids)).all()
inner_ids = {}
for b in borrows:
if b.source_table in model_map and b.stock_id:
inner_ids.setdefault(b.source_table, set()).add(b.stock_id)
inner_map = {}
for table, ids in inner_ids.items():
model = model_map[table]
for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all():
inner_map[(table, obj.id)] = _from_stock(obj)
for b in borrows:
info = inner_map.get((b.source_table, b.stock_id))
if info:
resolved[('trans_borrow', b.id)] = info
# 3) 维修单来源:物料名在 TransRepair 上,无规格
repair_ids = {r.stock_id for r in rows if r.source_table == 'trans_repair' and r.stock_id}
if repair_ids:
for rp in TransRepair.query.filter(TransRepair.id.in_(repair_ids)).all():
resolved[('trans_repair', rp.id)] = {
'material_name': rp.material_name or '',
'spec_model': '',
'warehouse_location': '',
'batch_number': getattr(rp, 'serial_number', '') or '',
}
return resolved
@staticmethod
def query_records(page=1, page_size=50, sku='', start_date='', end_date='',
keyword='', search_type='all', advanced_filters=None):
"""
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
· 有 scrap_request_no:按单号分组;
· 无单号(历史直接报废):按 操作时间(精确到分钟) + 操作人 虚拟分组,
并生成可读的虚拟单号,避免历史数据成为无主记录。
每单返回 items 明细数组、损失合计、申请人(取自 ScrapApproval)。
"""
query = TransScrap.query
if sku:
@ -328,6 +468,116 @@ class ScrapService:
if end_date:
query = query.filter(TransScrap.operation_time <= end_date + ' 23:59:59')
# 单号 / SKU 可在 SQL 层直接过滤
if keyword and search_type == 'no':
query = query.filter(TransScrap.scrap_request_no.ilike(f'%{keyword}%'))
elif keyword and search_type == 'sku':
query = query.filter(TransScrap.sku.ilike(f'%{keyword}%'))
# ====================================================================
# ★ 高级筛选
#
# 报废流水在 Python 侧按「单号 / (时间+操作人) 虚拟单号」分组,
# 且历史数据 scrap_request_no 为 NULL,无法像出库/借还那样直接对
# 单号列做 IN。因此这里统一用**行级等价键**表达父子关系:
#
# 1) 先用子级条件求出「命中行」;
# 2) 把命中行折算成"同单等价键"谓词;
# 3) 肯定操作符 → 放行同单全部行(OR),
# 否定操作符 → 整单排除(NOT ...)。
#
# 等价键:有单号 → scrap_request_no 相等;
# 无单号 → 同一分钟 + 同一操作人(与分组逻辑完全一致)。
# ====================================================================
if advanced_filters:
from app.utils.advanced_filter import (
build_predicate, is_negative, invert_condition,
)
from sqlalchemy import or_, and_, tuple_, func as _func
parent_map = {
'no': TransScrap.scrap_request_no,
'operator': TransScrap.operator_name,
}
child_map = {'sku': TransScrap.sku}
def _order_key_pred(rows):
"""把若干命中行折算成「同单」谓词(OR 连接)"""
keys = []
for h in rows:
if h.scrap_request_no:
keys.append(and_(
TransScrap.scrap_request_no.isnot(None),
TransScrap.scrap_request_no == h.scrap_request_no,
))
else:
keys.append(and_(
TransScrap.scrap_request_no.is_(None),
_func.date_trunc('minute', TransScrap.operation_time)
== _func.date_trunc('minute', h.operation_time),
TransScrap.operator_name == h.operator_name,
))
return or_(*keys) if keys else None
def _matched_rows(cond):
"""按肯定形式求出「命中该子级条件的报废行」"""
probe = invert_condition(cond)
field = probe.get('field')
if field == 'material_name':
# 物料名不在流水表:先在三张库存表求出 (source_table, stock_id)
# 命中集合,再用元组 IN 定位报废行。
# ★ 用元组 IN 而非"单号 IN":后者对 scrap_request_no 为 NULL 的
# 历史行永远匹配不上,会把历史数据整体漏掉。
from app.models.base import MaterialBase
name_col = MaterialBase.name
name_pred = (name_col == probe.get('value')
if probe.get('operator') == 'eq'
else name_col.ilike(f"%{probe.get('value')}%"))
pairs = []
for SM, sv in [(StockBuy, 'stock_buy'), (StockSemi, 'stock_semi'),
(StockProduct, 'stock_product')]:
for r in (SM.query.join(MaterialBase, SM.base_id == MaterialBase.id)
.filter(name_pred).all()):
pairs.append((sv, r.id))
if not pairs:
return None
return TransScrap.query.filter(
tuple_(TransScrap.source_table, TransScrap.stock_id).in_(pairs)
).all()
p = build_predicate(probe, child_map)
if p is None:
return None
return TransScrap.query.filter(p).all()
for cond in advanced_filters:
field = cond.get('field')
# --- 父级字段:标准 SQL 谓词,否定操作符语义无歧义 ---
if field in parent_map:
p = build_predicate(cond, parent_map)
if p is not None:
query = query.filter(p)
continue
if field not in child_map and field != 'material_name':
continue
# --- 子级字段:正/负操作符语义分派 ---
negative = is_negative(cond)
rows = _matched_rows(cond)
if not rows:
# 没有命中行:肯定 → 结果为空;否定 → 无需排除任何单
if not negative:
query = query.filter(db.false())
continue
key_pred = _order_key_pred(rows)
if key_pred is None:
continue
query = query.filter(~key_pred if negative else key_pred)
# 【行级数据隔离】基于 JWT 多租户公司过滤
# 通过 stock 表或 trans_repair 关联到 MaterialBase
company_limit = get_current_company_filter()
@ -385,84 +635,289 @@ class ScrapService:
# 按时间倒序
query = query.order_by(TransScrap.operation_time.desc())
total = query.count()
records = query.offset((page - 1) * page_size).limit(page_size).all()
# ==================================================================
# ★ 分组:先取全量(报废量级小),在内存里按「订单」聚合后再分页
# 有单号 → 按 scrap_request_no
# 无单号 → 按 操作时间(分钟) + 操作人 虚拟分组
# ==================================================================
rows = query.all()
mat_info = ScrapService._resolve_materials(rows)
# 遍历结果,补充操作人姓名、物料名称、规格
result_list = []
for r in records:
item = r.to_dict()
# 1. 解析操作人姓名
if r.operator_name:
# operator_name 可能是用户ID或用户名,尝试解析为真实姓名
try:
# 尝试将 operator_name 当作用户ID查询
user_id = int(r.operator_name)
user = SysUser.query.get(user_id)
if user:
# 解析存储格式: "张三/zhangsan"
raw_name = user.username
if '/' in raw_name:
item['operator_name'] = raw_name.split('/')[0]
except (ValueError, TypeError):
# 如果不是数字ID,保持原值
pass
# 2. 多态解析物料名称与规格
material_name = ''
spec_model = ''
if r.source_table == 'trans_borrow':
# 借库转报废:stock_id 指向 trans_borrow.id,通过源库存表取物料
from app.models.transaction import TransBorrow
borrow = TransBorrow.query.get(r.stock_id)
if borrow:
bstock_model = None
if borrow.source_table == 'stock_buy':
bstock_model = StockBuy.query.get(borrow.stock_id)
elif borrow.source_table == 'stock_semi':
bstock_model = StockSemi.query.get(borrow.stock_id)
elif borrow.source_table == 'stock_product':
bstock_model = StockProduct.query.get(borrow.stock_id)
if bstock_model and hasattr(bstock_model, 'base_id') and bstock_model.base_id:
base = MaterialBase.query.get(bstock_model.base_id)
if base:
material_name = base.name or ''
spec_model = base.spec_model or ''
elif r.source_table == 'trans_repair':
# 维修单
repair = TransRepair.query.get(r.stock_id)
if repair:
material_name = repair.material_name or ''
spec_model = ''
elif r.source_table in ['stock_buy', 'stock_semi', 'stock_product']:
# 常规库存表
stock_model = None
if r.source_table == 'stock_buy':
stock_model = StockBuy.query.get(r.stock_id)
elif r.source_table == 'stock_semi':
stock_model = StockSemi.query.get(r.stock_id)
elif r.source_table == 'stock_product':
stock_model = StockProduct.query.get(r.stock_id)
if stock_model and hasattr(stock_model, 'base_id') and stock_model.base_id:
base = MaterialBase.query.get(stock_model.base_id)
if base:
material_name = base.name or ''
spec_model = base.spec_model or ''
elif stock_model and hasattr(stock_model, 'base') and stock_model.base:
material_name = stock_model.base.name or ''
spec_model = stock_model.base.spec_model or ''
item['material_name'] = material_name
item['spec_model'] = spec_model
result_list.append(item)
groups = {}
for r in rows:
if r.scrap_request_no:
gkey = ('req', r.scrap_request_no)
else:
ts = r.operation_time.strftime('%Y-%m-%d %H:%M') if r.operation_time else '未知时间'
gkey = ('legacy', ts, r.operator_name or '')
g = groups.get(gkey)
if g is None:
op_name = ScrapService._display_name(r.operator_name)
if gkey[0] == 'req':
req_no = r.scrap_request_no
else:
# 虚拟单号:无单号的历史直接报废,仍给出可读标识便于追溯
ts_raw = r.operation_time.strftime('%Y%m%d%H%M') if r.operation_time else '000000000000'
req_no = f"LEGACY-{ts_raw}-{op_name or '未知'}"
g = {
'scrap_request_no': req_no,
'is_legacy': gkey[0] == 'legacy',
'operator_name': op_name,
'applicant_id': None,
'applicant_name': '',
'scrap_time': r.operation_time.strftime('%Y-%m-%d %H:%M:%S') if r.operation_time else '',
'_sort_time': r.operation_time,
'approval_status': r.approval_status or '',
'total_loss': 0.0,
'total_quantity': 0.0,
'reason': r.reason or '',
'items': [],
}
groups[gkey] = g
info = mat_info.get((r.source_table, r.stock_id), {})
qty = float(r.quantity or 0)
loss = float(r.total_loss or 0)
g['total_quantity'] += qty
g['total_loss'] += loss
g['items'].append({
'id': r.id,
'sku': r.sku or '',
'material_name': info.get('material_name', ''),
'spec_model': info.get('spec_model', ''),
'warehouse_location': info.get('warehouse_location', ''),
'batch_number': info.get('batch_number', ''),
'quantity': qty,
'reason': r.reason or '',
'source_table': r.source_table or '',
'loss_amount': round(loss, 2),
})
orders = list(groups.values())
# ★ 补申请人:有单号的取 ScrapApproval,无单号的以操作人兜底
req_nos = [o['scrap_request_no'] for o in orders if not o['is_legacy']]
if req_nos:
from app.models.scrap_approval import ScrapApproval
approver_cache = {}
for ap in ScrapApproval.query.filter(
ScrapApproval.request_no.in_(req_nos)
).all():
if ap.applicant_id not in approver_cache:
# 统一走 _display_name,去掉 username 里的 '/账号' 后缀
approver_cache[ap.applicant_id] = ScrapService._display_name(
ScrapApproval._user_name(ap.applicant_id)
)
o = groups.get(('req', ap.request_no))
if o:
o['applicant_id'] = ap.applicant_id
o['applicant_name'] = approver_cache.get(ap.applicant_id, '')
for o in orders:
if not o['applicant_name']:
o['applicant_name'] = o['operator_name']
o['total_loss'] = round(o['total_loss'], 2)
o['items'].sort(key=lambda x: x['sku'] or '')
# 关键词过滤:单号/SKU 已在 SQL 层处理,这里处理操作人/申请人/物料名
if keyword and search_type in ('all', 'name', 'material_name'):
kw = keyword.lower()
def _hit(o):
if search_type == 'name':
return kw in (o['operator_name'] or '').lower() or kw in (o['applicant_name'] or '').lower()
if search_type == 'material_name':
return any(kw in (it['material_name'] or '').lower() for it in o['items'])
return (kw in (o['scrap_request_no'] or '').lower()
or kw in (o['operator_name'] or '').lower()
or kw in (o['applicant_name'] or '').lower()
or any(kw in (it['sku'] or '').lower()
or kw in (it['material_name'] or '').lower()
or kw in (it['spec_model'] or '').lower() for it in o['items']))
orders = [o for o in orders if _hit(o)]
orders.sort(key=lambda o: (o['_sort_time'] is None, o['_sort_time']), reverse=True)
total = len(orders)
start = (page - 1) * page_size
paged = orders[start:start + page_size]
for o in paged:
o.pop('_sort_time', None)
return {
'list': result_list,
'list': paged,
'total': total,
'page': page,
'pageSize': page_size
}
# ==============================================================================
# 报废审批流(申请 → 审批 → 按单执行)—— 镜像 借库/出库 审批框架
# 与旧 /scrap(直接报废)并存,旧入口不动
# ==============================================================================
def _current_user_role():
try:
claims = get_jwt()
return (claims.get('role') or '').upper()
except Exception:
return ''
@scrap_bp.route('/request/check-approval', methods=['POST'])
@jwt_required()
@permission_required('scrap_apply')
def scrap_check_approval():
"""
提交前预检:报废一律需审批,本接口返回 need_approval=true,
并附带命中「需审批物料」标记的明细,供前端展示审批提示文案。
"""
try:
from app.services.approval_control import resolve_approval_control
from app.services.scrap_approval_service import SCRAP_ALWAYS_REQUIRES_APPROVAL
data = request.get_json() or {}
items = data.get('items', []) or []
_, flagged = resolve_approval_control(items)
return jsonify({'code': 200, 'msg': 'success',
'data': {'need_approval': SCRAP_ALWAYS_REQUIRES_APPROVAL,
'materials': flagged}}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'预检失败: {str(e)}'}), 500
@scrap_bp.route('/request', methods=['POST'])
@jwt_required()
@permission_required('scrap_apply')
def create_scrap_request():
"""提交报废申请(不扣库存;扣减在库管执行时)"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
identity = get_jwt_identity()
if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {}
req = ScrapApprovalService.submit_approval(
applicant_id=int(identity),
items=data.get('items', []),
allowed_approvers=data.get('allowed_approvers'),
remark=data.get('remark'),
approver_id=data.get('approver_id'),
force_approval=(_current_user_role() == 'WAREHOUSE_MGR'),
)
return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500
@scrap_bp.route('/request', methods=['GET'])
@jwt_required()
@permission_required('scrap_approval')
def list_scrap_requests():
"""
报废申请列表。
scope: mine(我提交的,默认) / pending(待我审批) / executable(待执行) / all(管理者)
Query: page, limit, status(可选覆盖)
"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
from app.utils.decorators import is_privileged_viewer
identity = int(get_jwt_identity())
scope = (request.args.get('scope') or 'mine').lower()
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
status = request.args.get('status')
status = int(status) if status not in (None, '', 'all') else None
priv = is_privileged_viewer()
kwargs = {'page': page, 'limit': limit, 'status': status}
if scope == 'pending':
kwargs['status'] = 0
kwargs['approver_id'] = identity
elif scope == 'executable':
kwargs['status'] = 1
elif scope == 'all':
if not priv:
kwargs['applicant_id'] = identity
else: # mine
kwargs['applicant_id'] = identity
data = ScrapApprovalService.get_list(**kwargs)
return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取报废申请列表失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>', methods=['GET'])
@jwt_required()
@permission_required('scrap_approval')
def get_scrap_request_detail(request_id):
try:
from app.models.scrap_approval import ScrapApproval
from app.utils.decorators import is_privileged_viewer
req = db.session.get(ScrapApproval, request_id)
if not req:
return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404
if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()):
# 被指定审批人也可查看
allowed = req.get_allowed_approvers() or []
if str(get_jwt_identity()) not in [str(a.get('value')) for a in allowed if a.get('type') == 'user']:
return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403
return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取报废申请详情失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>/approve', methods=['PATCH'])
@jwt_required()
@permission_required('scrap_approval')
def approve_scrap_request(request_id):
"""审批报废申请(仅被指定审批人)"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
data = request.get_json() or {}
req = ScrapApprovalService.approve(
request_id, int(get_jwt_identity()),
data.get('action'), data.get('reject_reason', '')
)
return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>/execute', methods=['POST'])
@jwt_required()
@permission_required('scrap_execute')
def execute_scrap_request(request_id):
"""
按单报废:有 scrap_execute 权限者执行,扣减实物库存并写报废流水。
Body: { "items": [{"source_table", "stock_id", "quantity", "name"/"sku"(可选)}, ...] }
实扫明细必须是该申请单批准明细的子集,且累计数量不得超过批准数量。
"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
identity = int(get_jwt_identity())
u = SysUser.query.get(identity)
data = request.get_json(silent=True) or {}
req = ScrapApprovalService.execute(
request_id,
operator_name=(u.username if u else str(identity)),
scanned_items=data.get('items') or [],
)
return jsonify({'code': 200, 'msg': '已执行报废并扣减库存', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'执行报废失败: {str(e)}'}), 500

View File

@ -162,6 +162,12 @@ def get_records():
page = int(request.args.get('page', 1))
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
# ★ 高级筛选:JSON 字符串 → 条件列表
from app.utils.advanced_filter import parse_advanced_filters
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
# ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录;管理者看全部
borrower_name = None
@ -175,7 +181,9 @@ def get_records():
res = TransService.get_records(
page=page, limit=10, status=status, keyword=keyword,
search_type=search_type, borrower_name=borrower_name
search_type=search_type, borrower_name=borrower_name,
start_date=start_date, end_date=end_date,
advanced_filters=advanced_filters,
)
# ★ service 层异常时:code==500 的字典(带 traceback),需要直通到前端,便于排查
@ -307,7 +315,7 @@ def approve_borrow_request(request_id):
@permission_required('op_borrow_approval')
def close_borrow_request(request_id):
"""
手动完结已通过的借库审批单(status 1-已通过 → 3-已完成)
手动完结已通过的借库审批单(status 1-已通过 → 4-已完结)
参照出库审批的完结逻辑,供库管/主管在未走扫码借出时强制完结
"""
try:

View File

@ -0,0 +1,96 @@
from app.extensions import db, beijing_time
import json
def _beijing_now():
"""
统一时间口径:naive 北京时间,与 created_at / OutboundApproval / BorrowApproval 一致。
★ 原先返回 tz-aware 时间,配合 timestamptz 列才正确;现已将该表列类型统一为
timestamp without time zone(见 db_migrations/unify_approval_timezone.sql),
故此处必须返回 naive 值,否则存库会被当 UTC 处理而早 8 小时。
"""
return beijing_time()
class ScrapApproval(db.Model):
"""
报废申请/审批单(镜像 BorrowApproval / OutboundApproval)
与出/借库的关键差异:items_json 里每条明细必须携带
source_table + stock_id(精准定位到某条实物库存记录),
因为报废从“申请那一刻”就要求指明具体要报废的东西。
"""
__tablename__ = 'scrap_approval'
id = db.Column(db.Integer, primary_key=True)
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
applicant_id = db.Column(db.Integer, nullable=False, index=True)
remark = db.Column(db.Text) # 报废原因
# 状态: 0-待审批, 1-已通过(待执行), 2-已驳回, 3-已执行(已报废)
status = db.Column(db.Integer, default=0, nullable=False, index=True)
allowed_approvers = db.Column(db.Text) # JSON: [{"type":"user","value":123}, ...]
actual_approver_id = db.Column(db.Integer, index=True)
approved_at = db.Column(db.DateTime)
reject_reason = db.Column(db.Text)
# 明细快照: [{"source_table","stock_id","sku","name","spec_model","location","batch_number","scrap_qty"}]
items_json = db.Column(db.Text, nullable=False)
# 执行信息
executed_at = db.Column(db.DateTime)
executor_name = db.Column(db.String(100))
created_at = db.Column(db.DateTime, default=_beijing_now, nullable=False)
updated_at = db.Column(db.DateTime, default=_beijing_now, onupdate=_beijing_now, nullable=False)
def set_items(self, items):
self.items_json = json.dumps(items or [], ensure_ascii=False)
def get_items(self):
try:
return json.loads(self.items_json) if self.items_json else []
except Exception:
return []
def set_allowed_approvers(self, approvers):
self.allowed_approvers = json.dumps(approvers or [], ensure_ascii=False)
def get_allowed_approvers(self):
try:
return json.loads(self.allowed_approvers) if self.allowed_approvers else []
except Exception:
return []
@staticmethod
def _user_name(user_id):
if not user_id:
return ''
try:
from app.models.system import SysUser
u = SysUser.query.get(user_id)
return u.username if u else ''
except Exception:
return ''
def to_dict(self):
return {
'id': self.id,
'request_no': self.request_no,
'applicant_id': self.applicant_id,
'applicant_name': self._user_name(self.applicant_id),
'remark': self.remark or '',
'status': self.status,
'allowed_approvers': self.get_allowed_approvers(),
'actual_approver_id': self.actual_approver_id,
'actual_approver_name': self._user_name(self.actual_approver_id),
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
'reject_reason': self.reject_reason or '',
'items': self.get_items(),
'executed_at': self.executed_at.strftime('%Y-%m-%d %H:%M:%S') if self.executed_at else None,
'executor_name': self.executor_name or '',
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
}

View File

@ -199,6 +199,8 @@ class TransScrap(db.Model):
approval_status = db.Column(db.String(20), default='pending')
cost_at_scrap = db.Column(db.Numeric(19, 4))
total_loss = db.Column(db.Numeric(19, 4))
# ★ 关联报废申请单号(审批流写台账用;DDL 见 db_migrations/add_scrap_approval.sql)
scrap_request_no = db.Column(db.String(100), index=True)
def to_dict(self):
return {

View File

@ -26,18 +26,70 @@ def _is_int(v):
return False
def _stock_models():
"""
延迟导入三张实物库存表。
★ material_base 表本身没有 sku 列,SKU 只存在于 stock_buy / stock_semi /
stock_product,所以按 SKU 反查物料时必须先经库存表做 sku → base_id 映射。
"""
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
return {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct,
}
def _material_by_sku(sku, source_table=None, cache=None):
"""
按 SKU 反查物料主表(经库存表 sku → base_id 映射后回查 material_base)。
优先用明细自带的 source_table 定位,命中不了再依次降级查其余两张表。
返回 MaterialBase 或 None(None 表示该 SKU 在库存表中查无对应物料)。
"""
sku = str(sku or '').strip()
if not sku:
return None
cache = cache if cache is not None else {}
if sku in cache:
return cache[sku]
models = _stock_models()
order = ([source_table] if source_table in models else []) + [
k for k in models if k != source_table
]
found = None
for key in order:
row = models[key].query.filter_by(sku=sku).first()
if row and row.base_id:
m = MaterialBase.query.get(int(row.base_id))
if m:
found = m
break
cache[sku] = found
return found
def resolve_approval_control(items):
"""
判断一批出库/借库申请明细是否需要审批。
判断一批出库/借库/报废申请明细是否需要审批。
判定优先级:
1) 方案1(最高):明细带有效 base_id → 直接按物料主键查 is_approval_required,
且只要有有效 ID,无论命中与否都不再做任何字符串兜底(杜绝同名/同规格误杀)。
2) 方案2(无 ID 兜底):名称必须 100% 精确一致 AND 核心规格一致(斜杠前段,
或前端未传规格视为防呆拦截),两条件同时成立才判定命中。
判定优先级(逐级降级,任一级给出结论即停止,不再往下兜底):
1) base_id —— 物料主键,最权威。只要有有效 ID,无论是否命中需审批都不再做字符串匹配
(杜绝同名/同规格误杀)。
2) SKU —— 经库存表 sku→base_id 反查物料。查到即定论;查无结果才降级。
3) 名称 + 规格 —— 仅当 base_id 与 SKU 均缺失、或两者均查无结果时才启用的字符串兜底。
名称必须 100% 精确一致 AND 核心规格一致(斜杠前段,或前端未传规格视为防呆拦截)。
"""
flagged = []
seen = set()
sku_cache = {}
def _record(m_obj, req_name):
if m_obj.id in seen:
@ -49,25 +101,38 @@ def resolve_approval_control(items):
'base_id': m_obj.id,
})
# 1. 查出所有“启用且需审批”的危险物料库
def _decide_by_material(m_obj, req_name):
"""给出物料的权威结论:命中需审批则记录;无论命中与否都不再降级"""
if m_obj and m_obj.is_enabled and m_obj.is_approval_required:
_record(m_obj, req_name)
# 1. 查出所有“启用且需审批”的危险物料库(字符串兜底时用)
flagged_materials = _materials_flagged()
for item in items or []:
name = str(item.get('name') or '').strip()
spec = str(item.get('spec_model') or '').strip()
base_id = item.get('base_id')
sku = item.get('sku')
source_table = item.get('source_table')
# --- 方案 1:最高优先级,依据 base_id 绝对判定 ---
# --- 优先级 1:base_id 绝对判定 ---
if base_id is not None:
bid = int(base_id) if _is_int(base_id) else 0
if bid > 0:
m = MaterialBase.query.get(bid)
if m and m.is_enabled and m.is_approval_required:
_record(m, name)
# ★ 关键:只要有有效 ID,无论是否需审批,绝不允许往下走字符串兜底
_decide_by_material(MaterialBase.query.get(bid), name)
# ★ 关键:只要有有效 ID,无论是否需审批,绝不允许往下走 SKU/字符串兜底
continue
# --- 方案 2:无 ID 时的降级兜底 (名称精确相等 AND 核心规格相等) ---
# --- 优先级 2:SKU 反查物料 ---
if str(sku or '').strip():
m = _material_by_sku(sku, source_table, sku_cache)
if m:
_decide_by_material(m, name)
# ★ 查到物料即定论,不再走字符串兜底
continue
# --- 优先级 3:无 ID 且 SKU 查无结果时的降级兜底 (名称精确相等 AND 核心规格相等) ---
if not name:
continue

View File

@ -1,6 +1,6 @@
from datetime import datetime
import pytz
from app.extensions import db
from app.extensions import db, beijing_time
from app.models.borrow import BorrowApproval
from app.models.system import SysUser
@ -139,7 +139,8 @@ class BorrowApprovalService:
)
else:
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
from datetime import datetime, timezone, timedelta
# ★ approved_at 必须与 created_at(beijing_time) 同为 naive 本地时间:
# approved_at 列是 timestamp without time zone,传 aware 时间会被驱动转成 UTC 存库,导致早 8 小时
approval = BorrowApproval(
request_no=request_no,
applicant_id=applicant_id,
@ -147,7 +148,7 @@ class BorrowApprovalService:
borrower_name=borrower_name,
status=1,
actual_approver_id=applicant_id,
approved_at=datetime.now(timezone(timedelta(hours=8))),
approved_at=beijing_time(),
)
approval.set_items(items)
@ -388,8 +389,8 @@ class BorrowApprovalService:
Returns:
(success: bool, message: str, approval: BorrowApproval or None)
"""
beijing_tz = pytz.timezone('Asia/Shanghai')
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
# ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径
current_time = beijing_time()
approval = BorrowApproval.query.get(request_id)
if not approval:
@ -464,18 +465,21 @@ class BorrowApprovalService:
@staticmethod
def mark_completed(request_id):
"""标记审批单为已完成(借库执行完成后调用)"""
"""
手动完结已通过的借库审批单(status 1-已通过 → 4-已完结,对齐出库审批的完结语义)。
注意:真正“扫码借出执行完成”走 trans_service.execute_dispatch,置 status=3(已完成)。
"""
approval = BorrowApproval.query.get(request_id)
if not approval:
return False, "审批单不存在", None
if approval.status != 1:
return False, f"只有已通过的审批单才能标记为完成 (当前状态: {approval.status})", None
return False, f"只有已通过的审批单才能标记为完结 (当前状态: {approval.status})", None
try:
approval.status = 3 # 已完成
approval.status = 4 # 已完结
db.session.commit()
return True, "审批单已完成", approval
return True, "审批单已完结", approval
except Exception as e:
db.session.rollback()
return False, f"操作失败: {str(e)}", None

View File

@ -2,7 +2,7 @@ import uuid # .material -> .base refactor checked
from datetime import datetime, timezone, timedelta
from sqlalchemy import or_, func, desc, and_
from sqlalchemy.orm import joinedload
from app.extensions import db
from app.extensions import db, beijing_time
from app.models.outbound import TransOutbound, OutboundApproval
# 引入所有库存模型以进行查询
@ -327,12 +327,13 @@ class OutboundService:
raise e
@staticmethod
def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None, consumer_name=None):
def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None, consumer_name=None, advanced_filters=None):
"""
查询出库记录(按出库单号分组),包含详细物品信息
支持跨表搜索:单号、领用人、SKU、物料名称、规格型号
search_type: all, no, name, sku, material_name, spec_model
company: 可选的公司过滤参数
advanced_filters: 高级筛选条件列表 [{'field','operator','value'}, ...]
"""
# 日期补全:解决零点截断问题
if end_date and len(str(end_date).strip()) == 10:
@ -536,6 +537,51 @@ class OutboundService:
if keyword_conditions is not None:
stmt = stmt.filter(keyword_conditions)
# ====================================================================
# ★ 高级筛选:父级字段直接过滤,子级字段(SKU/物料名称)走
# 「命中单号子查询 → 按单号 IN」的 EXISTS 语义。
#
# 绝不能写成 stmt.filter(TransOutbound.sku.ilike(...)):
# 那样会在 GROUP BY 前收窄明细范围,展开行里的兄弟明细会丢失。
# ====================================================================
if advanced_filters:
from app.utils.advanced_filter import (
build_predicate, apply_child_condition,
)
# 父级字段:单号/操作人本身就在流水表上,直接 filter(否定操作符
# 走标准 SQL != / NOT LIKE 即可,语义无歧义)
parent_field_map = {
'no': TransOutbound.outbound_no,
'operator': TransOutbound.operator_name,
'consumer_name': TransOutbound.consumer_name,
'outbound_type': TransOutbound.outbound_type,
}
# 子级字段:SKU 直接列 + 物料名称(需三表联查)
child_field_map = {'sku': TransOutbound.sku}
material_stock_models = [
(StockBuy, 'stock_buy'),
(StockSemi, 'stock_semi'),
(StockProduct, 'stock_product'),
]
for cond in advanced_filters:
field = cond.get('field')
if field in parent_field_map:
p = build_predicate(cond, parent_field_map)
if p is not None:
stmt = stmt.filter(p)
continue
if field in child_field_map or field == 'material_name':
# ★ 正/负操作符语义分派:否定 → 整单排除(NOT IN)
stmt = apply_child_condition(
stmt, TransOutbound.outbound_no, TransOutbound, cond,
child_field_map, material_stock_models,
)
continue
if start_date and end_date:
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
@ -816,7 +862,8 @@ class OutboundApprovalService:
)
else:
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
from datetime import datetime, timezone, timedelta
# ★ approved_at 必须与 created_at(beijing_time) 同为 naive 本地时间:
# approved_at 列是 timestamp without time zone,传 aware 时间会被驱动转成 UTC 存库,导致早 8 小时
approval = OutboundApproval(
request_no=request_no,
applicant_id=applicant_id,
@ -824,7 +871,7 @@ class OutboundApprovalService:
outbound_type=outbound_type,
status=1,
actual_approver_id=applicant_id,
approved_at=datetime.now(timezone(timedelta(hours=8))),
approved_at=beijing_time(),
)
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
@ -1006,8 +1053,8 @@ class OutboundApprovalService:
"""
from app.models.outbound import OutboundApproval
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
# ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径
current_time = beijing_time()
approval = OutboundApproval.query.get(request_id)
if not approval:

View File

@ -1,7 +1,7 @@
import json
from datetime import datetime, timezone, timedelta, date
from sqlalchemy import func
from app.extensions import db
from app.extensions import db, beijing_time
from app.models.purchase import PurchaseRequest
from app.models.base import MaterialBase
@ -145,8 +145,8 @@ class PurchaseService:
if not purchase:
raise ValueError("采购申请不存在")
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
# ★ approved_at 与 created_at(beijing_time) 同为 naive 本地时间,避免存库时被转成 UTC 早 8 小时
now = beijing_time()
# ★ 完结:库管将「已通过(1)」的申请单置为「已完结(4)」(仿出库审批)
if action == 'close':

View File

@ -0,0 +1,376 @@
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import func
from app.extensions import db, beijing_time
from app.models.scrap_approval import ScrapApproval
from app.models.transaction import TransScrap
logger = logging.getLogger(__name__)
def _beijing():
"""
统一时间口径:naive 北京时间。
★ scrap_approval 的时间列已统一为 timestamp without time zone
(见 db_migrations/unify_approval_timezone.sql),必须返回 naive 值,
否则 aware 值会被驱动转成 UTC 存库,比 created_at 早 8 小时。
"""
return beijing_time()
# =============================================================================
# ★ 业务规则(单一事实来源):报废一律需审批
#
# 与出库/借库不同,报废不过滤 is_approval_required —— 无论物料是否命中该标记,
# 所有报废申请都必须由指定审批人审批通过后才能执行。
# 前端 apply/index.vue 的「审批人」必填项与此规则保持一致。
# =============================================================================
SCRAP_ALWAYS_REQUIRES_APPROVAL = True
STOCK_MODELS = {}
def _stock_models():
"""延迟导入三张实物库存表,避免循环依赖"""
if not STOCK_MODELS:
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
STOCK_MODELS.update({
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct,
})
return STOCK_MODELS
class ScrapApprovalService:
@staticmethod
def generate_request_no():
now = _beijing()
prefix = f"APR-SCRAP-{now.strftime('%Y%m%d-%H%M')}-"
n = db.session.query(func.count(func.distinct(ScrapApproval.request_no))) \
.filter(ScrapApproval.request_no.like(f"{prefix}%")).scalar() or 0
return f"{prefix}{(n + 1):04d}"
# ------------------------------------------------------------------
# 提交申请
# ------------------------------------------------------------------
@staticmethod
def submit_approval(applicant_id, items, allowed_approvers=None, remark=None,
approver_id=None, force_approval=False):
"""
提交报废申请(仅锁定“意向”,不扣库存;扣减在库管执行时进行)
items 每项必须包含 source_table + stock_id(精准实物),可带 scrap_qty / 快照字段。
"""
if not items:
raise ValueError("报废明细不能为空")
models = _stock_models()
normalized = []
for idx, it in enumerate(items):
st = (it.get('source_table') or '').strip()
model = models.get(st)
sid = it.get('stock_id')
if not model or not sid:
raise ValueError(f"第 {idx + 1} 条报废明细必须指定 source_table 与 stock_id(精准库存行)")
try:
sid = int(sid)
except (TypeError, ValueError):
raise ValueError(f"第 {idx + 1} 条 stock_id 无效")
row = model.query.get(sid)
if not row:
raise ValueError(f"第 {idx + 1} 条对应的库存记录不存在")
try:
qty = float(it.get('scrap_qty') or 0)
except (TypeError, ValueError):
raise ValueError(f"第 {idx + 1} 条报废数量无效")
if qty <= 0:
raise ValueError(f"第 {idx + 1} 条报废数量必须大于 0")
avail = float(getattr(row, 'available_quantity', 0) or 0)
if qty > avail:
raise ValueError(f"第 {idx + 1} 条报废数量({qty})超过可用库存({avail})")
base = getattr(row, 'base', None)
normalized.append({
'source_table': st,
'stock_id': sid,
'base_id': getattr(row, 'base_id', None),
'sku': getattr(row, 'sku', '') or '',
'name': (base.name if base else '') or it.get('name') or '',
'spec_model': (base.spec_model if base else '') or it.get('spec_model') or '',
'location': getattr(row, 'warehouse_location', '') or '',
'batch_number': getattr(row, 'batch_number', '') or getattr(row, 'serial_number', '') or '',
'scrap_qty': qty,
'available_at_apply': avail,
})
# ★ 报废一律需审批(见 SCRAP_ALWAYS_REQUIRES_APPROVAL)。
# resolve_approval_control 仍调用,但仅用于生成「哪些物料命中需审批」的提示文案,
# 不再用它决定是否需要审批。
from app.services.approval_control import resolve_approval_control
_, flagged_materials = resolve_approval_control(normalized)
if not approver_id:
if flagged_materials:
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials)
raise ValueError(f"以下物料需审批报废:{_names}。请选择审批人后再提交")
raise ValueError("报废申请必须选择审批人后再提交")
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
req = ScrapApproval(
request_no=ScrapApprovalService.generate_request_no(),
applicant_id=applicant_id,
remark=remark,
)
req.set_items(normalized)
req.set_allowed_approvers(allowed_approvers)
# ★ 恒为「待审批」,不再走免审批自动通过分支
req.status = 0
db.session.add(req)
db.session.commit()
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}")
return req
# ------------------------------------------------------------------
# 列表
# ------------------------------------------------------------------
@staticmethod
def get_list(page=1, limit=10, status=None, applicant_id=None, approver_id=None):
query = ScrapApproval.query
if status is not None:
query = query.filter(ScrapApproval.status == status)
if applicant_id is not None:
query = query.filter(ScrapApproval.applicant_id == applicant_id)
if approver_id is not None:
query = query.filter(ScrapApproval.allowed_approvers.like(f'%"value": {approver_id}%'))
query = query.order_by(ScrapApproval.created_at.desc())
pg = query.paginate(page=page, per_page=limit, error_out=False)
return {
'items': [r.to_dict() for r in pg.items],
'total': pg.total,
'pages': pg.pages,
'current_page': page,
}
# ------------------------------------------------------------------
# 审批
# ------------------------------------------------------------------
@staticmethod
def approve(request_id, operator_id, action, reject_reason=None):
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
if req.status != 0:
raise ValueError("当前状态不允许审批(仅待审批可操作)")
# 仅被指定的审批人可操作
allowed = req.get_allowed_approvers() or []
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
if user_entries and str(operator_id) not in user_entries:
raise ValueError("只有被指定的审批人可以审批该申请")
if action == 'approve':
req.status = 1
req.actual_approver_id = operator_id
req.approved_at = _beijing()
req.reject_reason = None
elif action == 'reject':
req.status = 2
req.actual_approver_id = operator_id
req.reject_reason = reject_reason or '未说明原因'
else:
raise ValueError("无效的审批动作")
db.session.commit()
logger.info(f"[ScrapApproval] {req.request_no} 审批 {action} by {operator_id}")
return req
# ------------------------------------------------------------------
# 执行(按单报废:扣减实物库存 + 写报废流水)
# ------------------------------------------------------------------
@staticmethod
def _to_int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
@staticmethod
def _norm_sku(sku):
"""SKU 归一化:去首尾空白。空 SKU 返回 '',由调用方回退到行级匹配键。"""
return str(sku or '').strip()
@staticmethod
def _match_key(source_table, stock_id, sku):
"""
★ 扫码匹配键 —— SKU 优先。
已核验:SKU 在同一库存表内唯一,且不存在跨表重名(stock_buy /
stock_semi / stock_product 三表交叉无冲突),故 SKU 可安全作为
跨来源的稳定标识。
例外:个别历史库存行的 SKU 为空,这类行无法用 SKU 标识,回退为
source_table + stock_id 复合键。前缀区分('sku:' / 'row:')保证
空 SKU 行绝不会与任何正常 SKU 串键。
"""
s = ScrapApprovalService._norm_sku(sku)
if s:
return ('sku', s)
return ('row', f"{source_table}#{stock_id}")
@staticmethod
def _build_approved_index(items):
"""
申请单明细 → {匹配键: 批准信息}
每项保留其 source_table + stock_id 清单(rows),执行时据此定位到
批准时指定的那条库存记录做加锁扣减。同一 SKU 若在批准单中出现多行,
数量累加、rows 按批准顺序保留。
"""
index = {}
for it in items:
sid = ScrapApprovalService._to_int(it.get('stock_id'))
st = str(it.get('source_table') or '').strip()
if not st or sid is None:
continue
key = ScrapApprovalService._match_key(st, sid, it.get('sku'))
entry = index.setdefault(key, {
'qty': 0.0,
'label': ScrapApprovalService._norm_sku(it.get('sku'))
or it.get('name') or f"{st}#{sid}",
'rows': [],
})
qty = float(it.get('scrap_qty') or 0)
entry['qty'] += qty
entry['rows'].append({'source_table': st, 'stock_id': sid, 'qty': qty})
return index
@staticmethod
def _build_scanned_index(scanned_items, models):
"""前端实扫明细 → {匹配键: 累计扫码数量}"""
index = {}
for idx, s in enumerate(scanned_items):
sku = ScrapApprovalService._norm_sku(s.get('sku'))
st = str(s.get('source_table') or '').strip()
sid = ScrapApprovalService._to_int(s.get('stock_id'))
if not sku and (not st or sid is None):
raise ValueError(f"第 {idx + 1} 条扫码明细缺少 SKU,且无有效的 source_table / stock_id")
if st and st not in models:
raise ValueError(f"第 {idx + 1} 条扫码来源不支持:{st}")
qty = float(s.get('quantity') or 0)
if qty <= 0:
raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0")
key = ScrapApprovalService._match_key(st, sid, sku)
entry = index.setdefault(key, {
'qty': 0.0,
'label': sku or s.get('name') or f"{st}#{sid}",
})
entry['qty'] += qty
return index
@staticmethod
def execute(request_id, operator_name='System', scanned_items=None):
"""
按单执行报废:以「实际扫码明细」为准,按 SKU 匹配批准明细后扣减库存。
scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}]
· 以 SKU 为主校验键:扫码 SKU 必须在申请单 items_json 中存在;
· 同一 SKU 多次扫码累加,累计不得超过该 SKU 的批准总量;
· 允许合法子集(少扫 = 本次不报废该行);
· 扣减时以批准单配对的 source_table + stock_id 定位库存行加锁。
"""
models = _stock_models()
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
if req.status != 1:
raise ValueError("仅“已通过(待执行)”的报废单可执行")
approved_items = req.get_items()
if not approved_items:
raise ValueError("报废明细为空,无法执行")
# ★ 强制按单扫码:未提交实扫明细不允许执行
if not scanned_items:
raise ValueError("请先扫码并提交实际报废物料,再执行报废")
approved = ScrapApprovalService._build_approved_index(approved_items)
scanned = ScrapApprovalService._build_scanned_index(scanned_items, models)
# ★ 校验一:扫码 SKU 必须在批准明细内
for key, acc in scanned.items():
if key not in approved:
raise ValueError(
f"SKU【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
)
# ★ 校验二:同一 SKU 的累计扫码量不得超过批准总量
for key, acc in scanned.items():
appr = approved[key]
if acc['qty'] > appr['qty']:
raise ValueError(
f"SKU【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
)
# ★ 扣减:按批准单配对的 source_table + stock_id 定位库存行,逐行加锁扣减
for key, acc in scanned.items():
appr = approved[key]
remaining = acc['qty']
for ref in appr['rows']:
if remaining <= 0:
break
take = min(remaining, ref['qty'])
if take <= 0:
continue
st, sid = ref['source_table'], ref['stock_id']
row = models[st].query.with_for_update().get(sid)
if not row:
raise ValueError(f"库存记录已不存在({acc['label']})")
avail = float(getattr(row, 'available_quantity', 0) or 0)
stock = float(getattr(row, 'stock_quantity', 0) or 0)
if take > avail:
raise ValueError(f"库存 SKU【{acc['label']}】可用不足(剩 {avail}),无法报废 {take}")
if take > stock:
raise ValueError(f"库存 SKU【{acc['label']}】实物不足(剩 {stock}),无法报废 {take}")
# ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减
row.available_quantity = avail - take
row.stock_quantity = stock - take
db.session.flush()
# 写报废流水(台账)
db.session.add(TransScrap(
sku=getattr(row, 'sku', '') or acc['label'],
source_table=st,
stock_id=sid,
quantity=take,
reason=req.remark or '',
operator_name=operator_name,
approver_name=ScrapApproval._user_name(req.actual_approver_id),
approval_status='executed',
scrap_request_no=req.request_no,
))
remaining -= take
req.status = 3
req.executed_at = _beijing()
req.executor_name = operator_name
db.session.commit()
logger.info(
f"[ScrapApproval] {req.request_no} 执行报废完成 by {operator_name} "
f"(批准 {len(approved)} 项 / 实扫 {len(scanned)} 项)"
)
return req

View File

@ -381,10 +381,14 @@ class TransService:
if ModelClass:
stock = ModelClass.query.with_for_update().get(record.stock_id)
if stock:
stock.stock_quantity = float(stock.stock_quantity or 0) - pending_qty
# 防止负库存
if stock.stock_quantity < 0:
stock.stock_quantity = 0
stock_qty = float(stock.stock_quantity or 0)
# 不足则报错整单回滚:直接夹到 0 会让 available_quantity > stock_quantity,
# 凭空多出可用库存
if pending_qty > stock_qty:
raise ValueError(
f"SKU {record.sku} 实物库存不足(剩 {stock_qty}),无法报废 {pending_qty}"
)
stock.stock_quantity = stock_qty - pending_qty
# 3. 创建报废记录
scrap_record = TransScrap(
@ -410,7 +414,9 @@ class TransService:
raise e
@staticmethod
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all', borrower_name=None):
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all',
borrower_name=None, start_date=None, end_date=None,
advanced_filters=None):
"""
获取借还记录列表(按单号 borrow_no 维度分页,避免明细撑爆 pageSize)
@ -425,7 +431,14 @@ class TransService:
状态过滤按"单号聚合"判定:
- borrowed: 单号下至少有一条 is_returned=False
- returned: 单号下所有明细 is_returned=True
日期范围按「借出时间」过滤,边界补全时分秒以解决零点截断问题。
"""
# 日期补全:与出库记录同口径
if end_date and len(str(end_date).strip()) == 10:
end_date = f"{str(end_date).strip()} 23:59:59"
if start_date and len(str(start_date).strip()) == 10:
start_date = f"{str(start_date).strip()} 00:00:00"
try:
# ====================================================================
# 步骤 1a:构造"单号维度"基础子查询(GROUP BY borrow_no 在这里完成)
@ -676,6 +689,63 @@ class TransService:
order_subq.c.borrow_no.in_(company_borrow_nos_subq)
)
# ====================================================================
# ★ 高级筛选:父级字段直接过滤;子级字段(SKU/物料名称)走
# 「命中单号子查询 → 按单号 IN」的 EXISTS 语义,
# 避免在 GROUP BY 前收窄明细范围而丢失同单的兄弟明细。
# ====================================================================
if advanced_filters:
from app.utils.advanced_filter import (
build_predicate, apply_child_condition,
)
parent_map = {
'no': TransBorrow.borrow_no,
'operator': TransBorrow.borrower_name,
'borrower_name': TransBorrow.borrower_name,
}
child_map = {'sku': TransBorrow.sku}
material_stock_models = [
(StockBuy, 'stock_buy'),
(StockSemi, 'stock_semi'),
(StockProduct, 'stock_product'),
]
for cond in advanced_filters:
field = cond.get('field')
if field in parent_map:
# 父级字段:标准 SQL 谓词即可
p = build_predicate(cond, parent_map)
if p is not None:
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(
db.session.query(TransBorrow.borrow_no)
.filter(p).distinct()
)
)
continue
if field in child_map or field == 'material_name':
# ★ 子级字段:正/负操作符语义分派(否定 → 整单排除)
borrow_no_q = apply_child_condition(
borrow_no_q, order_subq.c.borrow_no, TransBorrow,
cond, child_map, material_stock_models,
)
continue
# 日期范围过滤(按借出时间;边界已在入口补全时分秒)
if start_date:
borrow_no_q = borrow_no_q.filter(order_subq.c.borrow_no.in_(
db.session.query(TransBorrow.borrow_no)
.filter(TransBorrow.borrow_time >= start_date)
.distinct()
))
if end_date:
borrow_no_q = borrow_no_q.filter(order_subq.c.borrow_no.in_(
db.session.query(TransBorrow.borrow_no)
.filter(TransBorrow.borrow_time <= end_date)
.distinct()
))
# 状态过滤(按"单号聚合"判定)
if status == 'borrowed':
# 单号下至少一条未还

View File

@ -0,0 +1,259 @@
"""
高级筛选(Advanced Filters)共享解析与谓词构建工具。
设计背景
--------
记录类接口(出库 / 借还 / 报废)对外返回的是**按单号分组的订单**(父),
而用户筛选用的字段往往落在**明细行**上(子),例如「SKU 包含 123」。
若直接在分组查询上加 .filter(TransOutbound.sku.ilike('%123%')),分组范围会
被一起收窄——GROUP BY 之后只剩匹配的那条明细,展开行里的其它兄弟明细会
凭空消失。正确做法是:先求出「含匹配明细的单号集合」,再让主查询按该集合
过滤,即 IN / EXISTS 子查询语义。
本模块只负责保证三个模块的操作符语义不漂移:
1. parse_advanced_filters() 解析并规整前端传来的 JSON 字符串
2. build_predicate() 把单个条件翻译成 SQLAlchemy 谓词
3. build_material_name_subquery() 物料名称的三表联查子查询(三模块共用)
字段 → 列的映射由各调用方提供(allowed_fields),因为三个模块的操作人/单号
列名不同。
"""
import json
import logging
logger = logging.getLogger(__name__)
# 与前端 operatorOptions 保持一致的受支持操作符
SUPPORTED_OPERATORS = {
'eq', 'ne', 'contains', 'not_contains', 'ge', 'le',
}
# ★ 否定操作符 → 其等价的肯定形式。
# 子级字段(SKU/物料名称)的否定语义必须借「肯定形式求反」来实现,
# 不能直接用 SQL 的 != / NOT LIKE,原因见 apply_child_condition 的说明。
NEGATIVE_OPERATORS = {
'ne': 'eq',
'not_contains': 'contains',
}
def invert_condition(condition):
"""
把否定条件翻转为等价的肯定条件(ne→eq,not_contains→contains)。
非否定操作符原样返回。
"""
op = condition.get('operator')
if op in NEGATIVE_OPERATORS:
flipped = dict(condition)
flipped['operator'] = NEGATIVE_OPERATORS[op]
return flipped
return dict(condition)
def is_negative(condition):
return condition.get('operator') in NEGATIVE_OPERATORS
def parse_advanced_filters(raw):
"""
解析 advancedFilters 查询参数。
前端传的是 JSON.stringify 后的数组字符串;同时兼容已被解析为 list 的情况
(测试或内部调用)。任何解析失败都退化为空列表(不影响主查询),
并记 warning 便于排查。
"""
if not raw:
return []
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (ValueError, TypeError) as e:
logger.warning(f"[advanced_filter] JSON 解析失败,已忽略: {e}")
return []
if not isinstance(raw, list):
logger.warning(f"[advanced_filter] 期望 list,实际 {type(raw).__name__},已忽略")
return []
parsed = []
for cond in raw:
if not isinstance(cond, dict):
continue
field = str(cond.get('field') or '').strip()
operator = str(cond.get('operator') or '').strip()
value = cond.get('value')
# 字段与操作符必须有效;值允许为 0 / '0',故用 is None 判定而非真值
if not field or operator not in SUPPORTED_OPERATORS or value is None:
continue
if isinstance(value, str) and value.strip() == '':
continue
parsed.append({'field': field, 'operator': operator, 'value': value})
return parsed
def build_predicate(condition, allowed_fields):
"""
把单个条件翻译成 SQLAlchemy 谓词。
allowed_fields: {field_name: Column} —— 调用方给出的白名单映射,
未登记的字段返回 None(杜绝任意列注入)。
数值型操作符 ge/le 在值无法转 float 时返回 None 跳过该条件,
与既有 buy_service 的处理保持一致,避免整条查询 500。
"""
field = condition.get('field')
operator = condition.get('operator')
value = condition.get('value')
column = allowed_fields.get(field)
if column is None:
return None
if operator == 'eq':
return column == value
if operator == 'ne':
return column != value
if operator == 'contains':
return column.ilike(f'%{value}%')
if operator == 'not_contains':
return ~column.ilike(f'%{value}%')
if operator == 'ge':
try:
return column >= float(value)
except (ValueError, TypeError):
return None
if operator == 'le':
try:
return column <= float(value)
except (ValueError, TypeError):
return None
return None
def build_material_name_select(model, order_no_column, stock_models, keyword,
operator='contains'):
"""
构造「物料名称命中」的单号查询(三表联查,出库/借还/报废共用)。
material_name 不在流水表上,需经 stock_buy / stock_semi / stock_product
三张库存表 JOIN material_base 才能取到。任一来源命中即算命中,故三路
union 后再交给调用方做 order_no_column.in_(...)。
这是"子级字段 → 父级订单"的核心:调用方对**单号**做 IN 过滤,
而不是对明细行做 filter,从而保住同单的其它兄弟明细。
参数
----
model : 流水模型(TransOutbound / TransBorrow / TransScrap)
order_no_column: 该模型上的单号列
stock_models : [(StockModel, source_table_value), ...]
keyword : 匹配值
operator : 'contains'(默认)或 'eq'
返回
----
可直接用于 order_no_column.in_(...) 的 select,或 None(无有效来源/无关键词)。
"""
if not keyword:
return None
if not stock_models:
return None
from sqlalchemy import and_
from app.models.base import MaterialBase
matched = MaterialBase.name == keyword if operator == 'eq' \
else MaterialBase.name.ilike(f'%{keyword}%')
selects = []
for StockModel, source_value in stock_models:
s = (
db_session_query(model, order_no_column)
.join(StockModel, and_(model.stock_id == StockModel.id,
model.source_table == source_value))
.join(MaterialBase, StockModel.base_id == MaterialBase.id)
.filter(matched)
)
selects.append(s)
combined = selects[0]
for extra in selects[1:]:
combined = combined.union(extra)
return combined
def db_session_query(model, *columns):
"""按需构造 Query(延迟导入 db,避免模块级导入环)"""
from app.extensions import db
return db.session.query(*columns)
def apply_child_condition(stmt, order_no_column, model, condition,
child_field_map, material_stock_models=None):
"""
★ 把**子级**条件应用到主查询上,正/负操作符语义有别。
为什么否定操作符不能直接用 SQL 的 != / NOT LIKE
---------------------------------------------------
订单是父、明细是子。若对明细行写 `sku != 'X'`,得到的是
「本单存在某条 SKU 不等于 X 的明细」——多明细单几乎必然成立,
于是「SKU 不等于 X」会返回几乎全部订单,与用户直觉相悖。
用户点「不等于」时的真实意图是:**整单排除**——只要单内任意一条明细
命中 X,整张单就不该出现。即 NOT EXISTS / NOT IN 语义。
实现方式
--------
肯定操作符: order_no IN (含匹配明细的单号)
否定操作符: order_no NOT IN (含匹配明细的单号)
两者的子查询完全一致(都用肯定形式的谓词),只是外层取反,
因此 ne / not_contains 只需翻转为 eq / contains 后走同一条路径。
参数
----
stmt : 主查询(已 GROUP BY 或待过滤的 Query)
order_no_column : 单号列
model : 流水模型(用于物料名称的三表联查)
condition : 单个高级筛选条件
child_field_map : {field: Column} 明细级直接字段白名单(如 sku)
material_stock_models : [(StockModel, source_table_value), ...],
提供后可支持 material_name 字段
返回
----
过滤后的 stmt;条件不可用(字段未登记/值为非法类型)时原样返回。
"""
from app.extensions import db
negative = is_negative(condition)
probe = invert_condition(condition) # 一律以肯定形式构造子查询
field = probe.get('field')
inner = None
if field == 'material_name':
if not material_stock_models:
return stmt
sel = build_material_name_select(
model, order_no_column, material_stock_models,
probe.get('value'), operator=probe.get('operator', 'contains'),
)
if sel is None:
return stmt
inner = sel
else:
p = build_predicate(probe, child_field_map)
if p is None:
return stmt
sub = db.session.query(order_no_column).filter(p).distinct().subquery()
inner = sub.select().with_only_columns(sub.c[0])
# ★ 否定 → 整单排除(NOT IN);肯定 → 整单命中(IN)
return stmt.filter(~order_no_column.in_(inner) if negative
else order_no_column.in_(inner))

View File

@ -26,3 +26,95 @@ export function getScrapRecords(params: any) {
params
})
}
// 3.1 报废申请专用库存列表(独立于出库选单权限,Fail-Closed 剥离价格)
export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) {
return request({
url: '/v1/scrap/stock-list',
method: 'get',
params
})
}
// ==========================================
// 报废申请/审批/执行(新流程,镜像出/借库)
// ==========================================
// 4. 提交报废申请
export function submitScrapRequest(data: {
items: Array<{
source_table: string
stock_id: number
scrap_qty: number
name?: string
spec_model?: string
sku?: string
location?: string
batch_number?: string
}>
remark?: string
approver_id?: number | null
allowed_approvers?: Array<{ type: string; value: any }>
}) {
return request({
url: '/v1/scrap/request',
method: 'post',
data
})
}
// 5. 提交前预检:所选物料是否需要审批
export function checkScrapApproval(data: { items: Array<{ name?: string; spec_model?: string; base_id?: number }> }) {
return request({
url: '/v1/scrap/request/check-approval',
method: 'post',
data
})
}
// 6. 报废申请列表(scope: mine | pending | executable | all)
export function getScrapApprovals(params: {
scope?: 'mine' | 'pending' | 'executable' | 'all'
status?: number | ''
page?: number
limit?: number
}) {
return request({
url: '/v1/scrap/request',
method: 'get',
params
})
}
// 7. 报废申请详情
export function getScrapApprovalDetail(id: number) {
return request({
url: `/v1/scrap/request/${id}`,
method: 'get'
})
}
// 8. 审批(通过/驳回)
export function approveScrapRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
return request({
url: `/v1/scrap/request/${id}/approve`,
method: 'patch',
data
})
}
// 9. 按单报废执行(提交实扫明细,扣减库存)
// items 必须是该申请单批准明细的子集,且累计数量不得超过批准数量
export function executeScrapByRequest(id: number, items: Array<{
source_table: string
stock_id: number
quantity: number
sku?: string
name?: string
}>) {
return request({
url: `/v1/scrap/request/${id}/execute`,
method: 'post',
data: { items }
})
}

View File

@ -251,6 +251,18 @@ const routes: Array<RouteRecordRaw> = [
meta: { title: '报废管理', icon: 'Delete' },
redirect: '/scrap/index',
children: [
{
path: 'apply',
name: 'ScrapApply',
component: () => import('@/views/operation/scrap/apply/index.vue'),
meta: { title: '报废申请' }
},
{
path: 'create',
name: 'ScrapCreate',
component: () => import('@/views/operation/scrap/create.vue'),
meta: { title: '按单报废' }
},
{
path: 'index',
name: 'ScrapList',
@ -258,10 +270,10 @@ const routes: Array<RouteRecordRaw> = [
meta: { title: '报废记录' }
},
{
path: 'create',
name: 'ScrapCreate',
component: () => import('@/views/operation/scrap/create.vue'),
meta: { title: '新建报废' }
path: 'approval',
name: 'ScrapApproval',
component: () => import('@/views/operation/scrap/approval/index.vue'),
meta: { title: '报废审批' }
}
]
},

View File

@ -10,6 +10,7 @@
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
<el-radio-button :label="4">已完结</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
</div>
@ -108,7 +109,7 @@
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
</el-tooltip>
</template>
<template v-else-if="row.status === 3">
<template v-else-if="row.status === 3 || row.status === 4">
<span style="color: #909399;">{{ getApproverName(row.actual_approver_id) }}</span>
</template>
<span v-else style="color: #c0c4cc;">-</span>
@ -254,14 +255,14 @@ const userNameCache = ref<Record<number, string>>({})
// --- 工具函数 ---
const statusText = (status: number) => {
const map: Record<number, string> = {
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '已完结'
}
return map[status] ?? '-'
}
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
0: 'warning', 1: 'success', 2: 'danger', 3: 'info', 4: 'info'
}
return map[status] ?? 'info'
}

View File

@ -0,0 +1,494 @@
<template>
<div class="app-container">
<el-card shadow="always">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="title">报废申请</span>
<span class="subtitle">(请添加需要报废的物品)</span>
</div>
<div>
<el-button type="danger" :disabled="cart.length === 0" @click="clearCart">清空列表</el-button>
<el-divider direction="vertical" />
<el-button type="primary" :icon="Plus" @click="openManualSelect">手动添加库存</el-button>
<el-button type="success" :icon="Select" :disabled="cart.length === 0" @click="openSubmitDialog">
提交报废申请
</el-button>
</div>
</div>
</template>
<el-alert
v-if="cart.length === 0"
title="清单为空,请点击右上角【手动添加库存】选择要报废的物品"
type="info"
center
show-icon
:closable="false"
style="margin-bottom: 20px"
/>
<!-- ★ 主表格 = 已选清单(购物车),不再承载搜索结果,避免搜索重置选中态 -->
<el-table
v-else
:data="cart"
border
row-key="uniqueKey"
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="typeLabel" label="类型" width="90" align="center" />
<el-table-column prop="name" label="物料名称" min-width="170" show-overflow-tooltip />
<el-table-column label="规格型号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.spec_model || '-' }}</template>
</el-table-column>
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
<el-table-column label="库位" width="120" show-overflow-tooltip>
<template #default="{ row }">
<span style="color:#409EFF;">{{ row.location || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="批次/序列号" width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.batch_number || '-' }}</template>
</el-table-column>
<el-table-column label="可用库存" width="110" align="right">
<template #default="{ row }">{{ row.available_qty }}</template>
</el-table-column>
<el-table-column label="本次报废" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-input-number
v-model="row.scrap_qty"
:min="1"
:max="row.available_qty"
:precision="0"
:controls="false"
size="small"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="removeCartRow(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="cart.length > 0" class="cart-summary">
共 <span class="num">{{ cart.length }}</span> 种物品,
合计报废 <span class="num">{{ totalScrapCount }}</span> 件
</div>
</el-card>
<!-- ============================================================
★ 选择库存物品(独立弹窗:搜索与已选解耦)
reserve-selection + row-key 保证跨搜索/翻页保留勾选
============================================================ -->
<el-dialog
v-model="manualDialogVisible"
title="选择库存物品"
width="85%"
top="5vh"
destroy-on-close
:close-on-click-modal="false"
>
<div class="filter-container">
<el-input
v-model="searchKeyword"
placeholder="请输入物料名称 / 规格型号 / SKU 进行搜索"
style="width: 320px"
:prefix-icon="Search"
clearable
@input="filterStock"
/>
<span class="filter-tip">
提示:点击表格行可勾选;<span style="color:#F56C6C;font-weight:bold;">修改数量会自动勾选</span>
</span>
</div>
<el-table
ref="manualTableRef"
:data="stockList"
v-loading="stockLoading"
height="500"
border
row-key="uniqueKey"
@selection-change="handleStockSelection"
@row-click="handleRowClick"
style="cursor: pointer"
>
<el-table-column type="selection" width="55" align="center" :reserve-selection="true" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="规格" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.standard || '-' }}</template>
</el-table-column>
<el-table-column prop="sku" label="SKU" width="130" show-overflow-tooltip />
<el-table-column label="库位" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.warehouse_location || '-' }}</template>
</el-table-column>
<el-table-column label="批次/序列号" width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.batch_number || row.serial_number || '-' }}</template>
</el-table-column>
<el-table-column prop="available_quantity" label="可用库存" width="110" align="right" />
<el-table-column label="本次报废" width="150" align="center" fixed="right">
<template #default="{ row }">
<!-- ★ @click.stop:防止点击数字框冒泡触发 @row-click 反转勾选 -->
<el-input-number
v-model="row.scrap_qty"
:min="1"
:max="Number(row.available_quantity || 0)"
:precision="0"
size="small"
style="width: 100%"
placeholder="0"
@click.stop
@change="(val: any) => handleManualQuantityChange(val, row)"
/>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 12px; justify-content: flex-end; display: flex;"
v-model:current-page="stockPage"
v-model:page-size="stockPageSize"
:total="stockTotal"
:page-sizes="[20, 50, 100, 200]"
layout="total, sizes, prev, pager, next"
background
@size-change="handleStockSizeChange"
@current-change="handleStockPageChange"
/>
<template #footer>
<span style="float:left; line-height:32px; color:#909399;">
已勾选 <b style="color:#409EFF;">{{ tempSelection.length }}</b> 项
<span v-if="alreadyInCartCount > 0" style="margin-left:6px;">
(其中 {{ alreadyInCartCount }} 项已在清单中)
</span>
</span>
<el-button @click="manualDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmManualAdd">确认添加</el-button>
</template>
</el-dialog>
<!-- 提交报废申请 -->
<el-dialog v-model="dialogVisible" title="提交报废申请" width="760px" destroy-on-close :close-on-click-modal="false">
<el-table :data="cart" border size="small" max-height="300">
<el-table-column prop="name" label="物料" min-width="150" show-overflow-tooltip />
<el-table-column label="规格" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.spec_model || '-' }}</template>
</el-table-column>
<el-table-column prop="location" label="库位" width="110" />
<el-table-column label="批次" width="120">
<template #default="{ row }">{{ row.batch_number || '-' }}</template>
</el-table-column>
<el-table-column label="可用" width="80" align="right">
<template #default="{ row }">{{ row.available_qty }}</template>
</el-table-column>
<el-table-column label="本次报废" width="140">
<template #default="{ row }">
<el-input-number v-model="row.scrap_qty" :min="1" :max="row.available_qty" :precision="0" :controls="false" style="width:100%" />
</template>
</el-table-column>
</el-table>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="margin-top:14px;">
<!-- ★ 业务规则:所有报废申请一律需审批,审批人恒为必填项 -->
<el-form-item label="指定审批人" prop="approver_id">
<div v-if="approvalText" style="color:#E6A23C; font-size:12px; line-height:1.4; margin-bottom:4px;">{{ approvalText }}</div>
<el-select v-model="form.approver_id" placeholder="请选择审批人" style="width:100%" filterable>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="报废原因" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请填写报废原因(必填)" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible=false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">确认提交</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox, ElTable, ElForm } from 'element-plus'
import { Plus, Search, Select } from '@element-plus/icons-vue'
import { getScrapStockList } from '@/api/scrap'
import { getApproversList } from '@/api/auth'
import { submitScrapRequest, checkScrapApproval } from '@/api/scrap'
// --- 已选清单(唯一数据源,主表格 / 选择弹窗 / 提交弹窗共享同一份对象) ---
const cart = ref<any[]>([])
// --- 选择库存物品弹窗 ---
const manualDialogVisible = ref(false)
const manualTableRef = ref<InstanceType<typeof ElTable>>()
const stockList = ref<any[]>([]) // 弹窗内搜索结果(服务端分页)
const stockTotal = ref(0)
const stockPage = ref(1)
const stockPageSize = ref(20)
const stockLoading = ref(false)
const searchKeyword = ref('')
const tempSelection = ref<any[]>([]) // 弹窗内暂存勾选
let stockSearchTimer: ReturnType<typeof setTimeout> | null = null
// --- 提交对话框 ---
const dialogVisible = ref(false)
const submitting = ref(false)
const formRef = ref<InstanceType<typeof ElForm>>()
const approvers = ref<any[]>([])
const approvalText = ref('')
// ★ 预检结果:提交前由 checkScrapApproval 写入,供文案提示使用
const isApprovalRequired = ref(false)
const form = reactive({
approver_id: null as number | null,
remark: '',
})
// ★ 业务规则:所有报废申请一律需审批 → 审批人与报废原因恒为必填
const rules = {
approver_id: [{ required: true, message: '请选择审批人', trigger: 'change' }],
remark: [{ required: true, message: '请填写报废原因', trigger: 'blur' }],
}
// ============================================================
// 库存行的唯一键:三张库存表主键各自独立,必须带表名前缀防跨表碰撞。
// source_table 由后端稳定注入,取值恰好就是提交报废申请所需的 source_table。
// ============================================================
const stockKey = (row: any): string => `${row.source_table}_${row.id}`
// 库存行 → 购物车项
const toCartItem = (row: any) => ({
uniqueKey: stockKey(row),
source_table: row.source_table,
stock_id: row.id,
base_id: row.base_id,
name: row.name || row.material_name || '',
spec_model: row.standard || row.spec_model || '',
sku: row.sku || '',
location: row.warehouse_location || row.warehouse_loc || '',
batch_number: row.batch_number || row.serial_number || '',
available_qty: Number(row.available_quantity || 0),
scrap_qty: Number(row.scrap_qty) || 1,
})
// --- 计算属性 ---
const totalScrapCount = computed(() =>
cart.value.reduce((sum, it) => sum + (Number(it.scrap_qty) || 0), 0)
)
const alreadyInCartCount = computed(() => {
const keys = new Set(cart.value.map(it => it.uniqueKey))
return tempSelection.value.filter(it => keys.has(stockKey(it))).length
})
// --- 加载库存(服务端搜索 + 分页)---
// 注意:不传 is_aggregated —— 报废要求 stock_id 精确到单条库存行,
// 聚合后 id 会退化为组内代表项,导致「申请扣 A 行、实际报废 B 行」。
const loadStockList = async () => {
stockLoading.value = true
try {
const res: any = await getScrapStockList({
page: stockPage.value,
pageSize: stockPageSize.value,
keyword: searchKeyword.value.trim(),
})
// ★ uniqueKey 必须物化到行对象上:reserve-selection 依赖 row-key 真实存在
stockList.value = (res.data?.list || []).map((item: any) => ({
...item,
uniqueKey: stockKey(item),
// 后端 to_dict 只输出 warehouse_loc,统一归一化,否则库位列恒为空
warehouse_location: item.warehouse_location || item.warehouse_loc || item.full_path || '',
}))
stockTotal.value = res.data?.total || 0
} catch (e) {
ElMessage.error('加载库存失败')
} finally {
stockLoading.value = false
}
}
const openManualSelect = async () => {
manualDialogVisible.value = true
stockPage.value = 1
searchKeyword.value = ''
tempSelection.value = []
await loadStockList()
}
// 搜索防抖 → 服务端过滤
const filterStock = () => {
if (stockSearchTimer) clearTimeout(stockSearchTimer)
stockSearchTimer = setTimeout(() => {
stockPage.value = 1
loadStockList()
}, 350)
}
const handleStockPageChange = (page: number) => {
stockPage.value = page
loadStockList()
}
const handleStockSizeChange = (size: number) => {
stockPageSize.value = size
stockPage.value = 1
loadStockList()
}
const handleStockSelection = (val: any[]) => { tempSelection.value = val }
// 点击行任意位置切换勾选(undefined = 取反)
const handleRowClick = (row: any) => {
manualTableRef.value?.toggleRowSelection(row, undefined)
}
// 弹窗内数量变化联动勾选
const handleManualQuantityChange = (val: number | undefined, row: any) => {
if (val && val > 0) manualTableRef.value?.toggleRowSelection(row, true)
else manualTableRef.value?.toggleRowSelection(row, false)
}
// 确认添加:按 uniqueKey 回查当前页行取权威 scrap_qty(tempSelection 可能是旧对象快照)
const confirmManualAdd = () => {
if (tempSelection.value.length === 0) return ElMessage.warning('请先勾选需要添加的物品')
const existing = new Set(cart.value.map(it => it.uniqueKey))
const toAdd: any[] = []
for (const it of tempSelection.value) {
const key = stockKey(it)
if (existing.has(key)) continue
// 优先用当前 stockList 里的同名行,保证拿到的是最新数量
const fresh = stockList.value.find(s => stockKey(s) === key) || it
toAdd.push(toCartItem(fresh))
existing.add(key)
}
if (toAdd.length === 0) {
manualDialogVisible.value = false
return ElMessage.warning('选中的物品已全部在清单中')
}
cart.value.push(...toAdd)
manualDialogVisible.value = false
tempSelection.value = []
ElMessage.success(`成功添加 ${toAdd.length} 项物品`)
}
// --- 清单维护 ---
const removeCartRow = (row: any) => {
const idx = cart.value.findIndex(it => it.uniqueKey === row.uniqueKey)
if (idx > -1) cart.value.splice(idx, 1)
}
const clearCart = () => {
ElMessageBox.confirm('确定要清空已选清单吗?', '提示', { type: 'warning' })
.then(() => { cart.value = [] })
.catch(() => {})
}
onBeforeUnmount(() => {
if (stockSearchTimer) clearTimeout(stockSearchTimer)
})
// --- 提交 ---
const loadApprovers = async () => {
try {
const res: any = await getApproversList()
approvers.value = res.data || []
} catch (e) { approvers.value = [] }
}
const buildItems = () => cart.value.map(it => ({
source_table: it.source_table,
stock_id: it.stock_id,
scrap_qty: Number(it.scrap_qty || 0),
name: it.name,
spec_model: it.spec_model,
sku: it.sku,
location: it.location,
batch_number: it.batch_number,
}))
// ★ 预检:调用 checkScrapApproval,把结果写入 isApprovalRequired 并生成提示文案。
// 所有报废申请一律需审批,此处主要用于向用户展示「哪些物料触发了审批」。
const runApprovalPrecheck = async (items: any[]) => {
try {
const chk: any = await checkScrapApproval({ items })
isApprovalRequired.value = true // 业务上恒需审批
const flagged: any[] = chk?.data?.materials || []
approvalText.value = flagged.length > 0
? `以下物料需审批报废:${flagged.map((m: any) => `${m.name}(${m.spec_model || '-'})`).join(';')}`
: '所有报废申请均需审批,请选择审批人'
} catch (e) {
isApprovalRequired.value = true
approvalText.value = '所有报废申请均需审批,请选择审批人'
}
}
const openSubmitDialog = async () => {
if (cart.value.length === 0) return ElMessage.warning('请先添加要报废的物品')
form.approver_id = null
form.remark = ''
isApprovalRequired.value = true
approvalText.value = '所有报废申请均需审批,请选择审批人'
formRef.value?.clearValidate()
await loadApprovers()
// 打开弹窗即预检,让用户在填写前就看到需审批提示
await runApprovalPrecheck(buildItems())
dialogVisible.value = true
}
const submit = async () => {
if (!formRef.value) return
// ★ 表单校验:审批人与报废原因均为必填
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
submitting.value = true
try {
const items = buildItems()
await runApprovalPrecheck(items)
await submitScrapRequest({
items,
remark: form.remark.trim(),
approver_id: form.approver_id,
})
ElMessage.success('报废申请已提交,待审批人审批')
dialogVisible.value = false
cart.value = []
} catch (err: any) {
// 拦截器已按后端 data.msg 弹业务错误,HTTP 错误不重复
if (!err?.response) ElMessage.error('网络异常,请重试')
} finally {
submitting.value = false
}
})
}
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.header-left { display: flex; align-items: baseline; gap: 8px; }
.title { font-size: 18px; font-weight: bold; }
.subtitle { font-size: 12px; color: #909399; }
.filter-container { display: flex; align-items: center; gap: 15px; margin-bottom: 12px; }
.filter-tip { color: #909399; font-size: 12px; }
.cart-summary { margin-top: 15px; text-align: right; color: #606266; font-size: 14px; }
.cart-summary .num { color: #F56C6C; font-weight: bold; }
</style>

View File

@ -0,0 +1,295 @@
<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已执行</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
<el-radio-group v-model="scope" size="default" style="margin-left:12px;" @change="handleStatusChange">
<el-radio-button label="pending">待我审批</el-radio-button>
<el-radio-button label="executable">待执行</el-radio-button>
<el-radio-button label="mine">我提交的</el-radio-button>
<el-radio-button label="all">全部单</el-radio-button>
</el-radio-group>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="list"
border
stripe
style="margin-top: 16px;"
row-key="id"
:expand-row-keys="expandedRows"
@expand-change="handleExpandChange"
>
<!-- 展开行:本次报废对应的“选单内容”(冻结库存行) -->
<el-table-column type="expand" width="60" align="center">
<template #default="{ row }">
<div style="padding: 12px 24px; background: #f5f7fa;">
<p style="margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266;">
报废明细(共 {{ row.items?.length || 0 }} 项)
</p>
<el-table v-if="row.items?.length" :data="row.items" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="来源" width="90" align="center">
<template #default="{ row: item }">
<el-tag size="small">{{ sourceLabel(item.source_table) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
<el-table-column prop="location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="batch_number" label="批次/序列号" width="140" show-overflow-tooltip />
<el-table-column label="报废数量" width="100" align="center">
<template #default="{ row: item }">
<span style="color: #F56C6C; font-weight: bold;">{{ item.scrap_qty ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无报废明细" :image-size="60" />
</div>
</template>
</el-table-column>
<el-table-column prop="request_no" label="申请单号" width="200">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
{{ row.request_no }}
</el-link>
</template>
</el-table-column>
<el-table-column label="申请人" width="140">
<template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template>
</el-table-column>
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
<el-table-column label="报废种类" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.items?.length || 0 }} 项</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="审批信息" width="180">
<template #default="{ row }">
<template v-if="row.status === 1 || row.status === 3">
<span style="color: #67C23A;">{{ row.actual_approver_name || getApproverName(row.actual_approver_id) }}</span>
<br />
<span style="font-size: 12px; color: #909399;">{{ row.approved_at || '' }}</span>
</template>
<template v-else-if="row.status === 2">
<span style="color: #F56C6C;">已驳回</span>
<el-tooltip v-if="row.reject_reason" :content="row.reject_reason" placement="top">
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
</el-tooltip>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button
v-if="userStore.hasPermission('scrap_approval:operation') || userStore.role === 'SUPER_ADMIN'"
type="success" size="small" :loading="row._approving" @click="handleApprove(row)"
>通过</el-button>
<el-button
v-if="userStore.hasPermission('scrap_approval:operation') || userStore.role === 'SUPER_ADMIN'"
type="danger" size="small" :loading="row._approving" @click="openRejectDialog(row)"
>驳回</el-button>
</template>
<!-- ★ 已通过(待执行):持有 scrap_execute 者可执行按单报废 -->
<template v-else-if="row.status === 1">
<el-button
v-if="canExecute"
type="warning" plain size="small" :loading="row._executing" @click="openExecute(row)"
>执行报废</el-button>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- 驳回原因 Dialog -->
<el-dialog v-model="rejectDialogVisible" title="驳回报废申请" width="480px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请填写驳回原因" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Refresh, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getScrapApprovals, approveScrapRequest } from '@/api/scrap'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const scope = ref<'pending' | 'executable' | 'mine' | 'all'>('mine')
const expandedRows = ref<string[]>([])
// 驳回 Dialog
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
const rejectLoading = ref(false)
const userNameCache = ref<Record<number, string>>({})
// --- 权限 ---
const canExecute = computed(() =>
userStore.role === 'SUPER_ADMIN' || userStore.hasPermission('scrap_execute')
)
// --- 工具 ---
const statusText = (status: number) => {
const map: Record<number, string> = { 0: '待审批', 1: '已通过', 2: '已驳回', 3: '已执行' }
return map[status] ?? '-'
}
const statusTagType = (status: number) => {
const map: Record<number, string> = { 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }
return map[status] ?? 'info'
}
const sourceLabel = (st: string) => ({ stock_buy: '采购件', stock_semi: '半成品', stock_product: '成品' } as any)[st] || st || '-'
const getApplicantName = (id: number | null) => (id ? (userNameCache.value[id] ?? `用户 #${id}`) : '-')
const getApproverName = (id: number | null) => (id ? (userNameCache.value[id] ?? `用户 #${id}`) : '-')
// --- 展开行 ---
const toggleExpand = (row: any) => {
const idx = expandedRows.value.indexOf(row.id)
if (idx > -1) expandedRows.value.splice(idx, 1)
else expandedRows.value.push(row.id)
}
const handleExpandChange = () => { /* 由 expandedRows 控制 */ }
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = { page: page.value, limit: pageSize.value, scope: scope.value }
if (filterStatus.value !== '') params.status = filterStatus.value
const res: any = await getScrapApprovals(params)
const records = res.data?.items || []
records.forEach((r: any) => {
if (r.applicant_id && r.applicant_name) userNameCache.value[r.applicant_id] = r.applicant_name
if (r.actual_approver_id && r.actual_approver_name) userNameCache.value[r.actual_approver_id] = r.actual_approver_name
r._approving = false
r._executing = false
})
list.value = records
total.value = res.data?.total || records.length || 0
} catch (err: any) {
ElMessage.error(err?.msg || '加载报废审批列表失败')
} finally {
loading.value = false
}
}
// --- 筛选 / 分页 ---
const handleStatusChange = () => { page.value = 1; expandedRows.value = []; fetchData() }
const handlePageChange = (p: number) => { page.value = p; fetchData() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要通过报废申请单 【${row.request_no}】 吗?`, '审批确认',
{ confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info' })
} catch { return }
row._approving = true
try {
await approveScrapRequest(row.id, { action: 'approve' })
ElMessage.success(`申请单 ${row.request_no} 已通过`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '审批操作失败')
} finally { row._approving = false }
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
const reason = rejectReason.value.trim()
if (!reason) return ElMessage.warning('请填写驳回原因')
rejectLoading.value = true
try {
await approveScrapRequest(currentRejectRow.value.id, { action: 'reject', reject_reason: reason })
ElMessage.success(`申请单 ${currentRejectRow.value.request_no} 已驳回`)
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '驳回操作失败')
} finally { rejectLoading.value = false }
}
// --- 按单报废执行:跳转到「按单报废执行」扫码页,由实扫明细驱动扣减 ---
const openExecute = (row: any) => {
router.push({ path: '/scrap/create', query: { requestId: row.id } })
}
onMounted(() => { fetchData() })
</script>
<style scoped>
.app-container { padding: 20px; }
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
</style>

View File

@ -4,62 +4,163 @@
<template #header>
<div class="card-header">
<div class="title-box">
<span>报废作业</span>
<span>按单报废执行</span>
<el-tag v-if="cartItems.length > 0" type="danger" size="small" effect="dark">
已选 {{ cartItems.length }} 项
已扫 {{ cartItems.length }} 项
</el-tag>
</div>
</div>
</template>
<div class="scan-section">
<div v-if="hasPermission" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
<div class="mode-switch-bar">
<el-tag type="danger" size="large" style="font-size: 14px;">按单执行</el-tag>
<span class="mode-hint">请先选择已审批通过的报废申请单,再扫码报废</span>
</div>
<!-- Step 1:选择已批准的报废申请单 -->
<div class="approval-request-select">
<el-select
v-model="selectedRequestId"
placeholder="请选择已审批通过的报废申请单"
filterable
clearable
style="width: 100%"
:loading="requestsLoading"
@change="handleRequestChange"
>
<el-option
v-for="req in approvalRequests"
:key="req.id"
:value="req.id"
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<el-divider direction="vertical" />
<span>{{ req.applicant_name || '未知申请人' }}</span>
<el-divider direction="vertical" />
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
</el-option>
</el-select>
<p class="select-tip">仅显示已通过(status=1)的报废申请单</p>
</div>
<!-- Step 2:待执行清单 -->
<div v-if="selectedRequest" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">待执行报废清单</span>
<el-tag type="danger" size="small">{{ plannedItems.length }} 种</el-tag>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
<el-table :data="plannedItems" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="来源" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ sourceLabel(row.source_table) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="110" show-overflow-tooltip />
<el-table-column prop="location" label="库位" width="100" show-overflow-tooltip />
<el-table-column label="批准数量" width="90" align="center">
<template #default="{ row }">
<span style="color: #E6A23C; font-weight: bold;">{{ row.scrap_qty ?? '-' }}</span>
</template>
</el-table-column>
<el-table-column label="已扫" width="80" align="center">
<template #default="{ row }">
<span :style="{ color: scannedQtyOf(row) >= Number(row.scrap_qty) ? '#67C23A' : '#909399', fontWeight: 'bold' }">
{{ scannedQtyOf(row) }}
</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="scan-section">
<!-- 扫码进度条 -->
<div v-if="selectedRequest && scanTotalQty > 0" class="scan-progress">
<div class="progress-info">
<span>扫码进度</span>
<el-tag type="success" size="small">{{ scanScannedTypes }}/{{ scanTotalTypes }} 种</el-tag>
<el-tag type="primary" size="small">{{ scanScannedQty }}/{{ scanTotalQty }} 件</el-tag>
<el-button
v-if="unscannedCount > 0"
type="warning" plain size="small" style="margin-left: auto;"
@click="showUnscannedDialog = true"
>
未扫清单 ({{ unscannedCount }})
</el-button>
</div>
<el-progress
:percentage="scanTotalQty > 0 ? Math.min(100, Math.round(scanScannedQty / scanTotalQty * 100)) : 0"
:stroke-width="8"
:color="scanScannedQty >= scanTotalQty ? '#67C23A' : '#F56C6C'"
/>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
:disabled="!hasPermission"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput" :disabled="!hasPermission">添加</el-button>
</template>
</el-input>
</div>
<template v-if="!selectedRequestId">
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">请先选择报废申请单</span>
</div>
<div class="input-box">
<el-input v-model="barcodeInput" placeholder="请先选择报废申请单" disabled size="large">
<template #prefix><el-icon><Scissor /></el-icon></template>
<template #append><el-button disabled>添加</el-button></template>
</el-input>
</div>
</template>
<template v-else>
<div v-if="hasPermission" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
:disabled="!hasPermission"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput" :disabled="!hasPermission">添加</el-button>
</template>
</el-input>
</div>
</template>
</div>
<div class="cart-section">
<div v-if="cartItems.length > 0">
<el-table :data="cartItems" border stripe style="width: 100%">
<el-table-column prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="可用库存" width="90" align="center">
<el-table-column label="库存" width="70" align="center">
<template #default="{row}">
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
<el-tag :type="parseFloat(row.available_quantity) > 0 ? 'success' : 'danger'" size="small">
{{ parseFloat(row.available_quantity) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="报废数" width="130" align="center">
<template #default="{row}">
<el-input-number
v-model="row.quantity"
v-model="row.scrap_quantity"
:min="1"
:max="parseFloat(row.available_quantity)"
:max="maxScrapFor(row)"
size="small"
style="width: 100px"
:disabled="!hasPermission"
@ -67,14 +168,6 @@
</template>
</el-table-column>
<el-table-column label="损失金额" width="100" align="center">
<template #default="{row}">
<span style="color: #F56C6C; font-weight: bold;">
¥{{ ((row.price || 0) * row.quantity).toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column v-if="hasPermission" label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
@ -86,29 +179,17 @@
</div>
<div v-if="cartItems.length > 0" class="form-section">
<el-divider content-position="left">报废登记信息</el-divider>
<el-divider content-position="left">报废执行信息</el-divider>
<el-form :model="form" ref="formRef" label-position="top">
<el-form-item label="报废原因" prop="reason" required>
<el-input
v-model="form.reason"
type="textarea"
:rows="3"
placeholder="请详细填写报废原因,如:设备老化、损坏严重等"
size="large"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="备注说明" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="补充说明..." />
<el-form :model="form" label-position="top">
<el-form-item label="报废原因(来自申请单)" prop="reason">
<el-input v-model="form.reason" type="textarea" :rows="2" disabled />
</el-form-item>
<div class="bottom-actions">
<el-button v-if="hasPermission" @click="clearAll" icon="Refresh">清空</el-button>
<el-button v-if="hasPermission" @click="clearAll" icon="Refresh">重扫</el-button>
<el-button v-if="hasPermission" type="danger" size="large" :loading="loading" @click="submitForm" icon="Select">
确认报废
确认执行报废
</el-button>
</div>
</el-form>
@ -127,26 +208,60 @@
</div>
<div class="scanner-footer">
<p>请将条码/二维码放入镜头范围</p>
<p v-if="cartItems.length > 0" class="current-count">已添加: {{ cartItems.length }} 项</p>
<p>请对准条形码,识别成功后自动添加</p>
<p v-if="cartItems.length > 0" class="current-count">当前已添加: {{ scanScannedQty }} 件</p>
</div>
</div>
<!-- 未扫清单弹窗 -->
<el-dialog v-model="showUnscannedDialog" title="未扫清单" width="600px" destroy-on-close>
<el-alert
v-if="unscannedList.length > 0"
:title="`以下 ${unscannedList.length} 种物料还未扫满,请补扫(未扫满也可提交,将按实扫数量报废):`"
type="warning" :closable="false" show-icon style="margin-bottom: 12px;"
/>
<el-table :data="unscannedList" border size="small" max-height="400">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="name" label="物料名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" min-width="110" show-overflow-tooltip />
<el-table-column label="批准" width="70" align="center">
<template #default="{ row }">{{ row.planQty }}</template>
</el-table-column>
<el-table-column label="已扫" width="70" align="center">
<template #default="{ row }">
<span style="color: #67C23A;">{{ row.scannedQty }}</span>
</template>
</el-table-column>
<el-table-column label="待扫" width="70" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.remaining }}</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="showUnscannedDialog = false">关闭</el-button>
<el-button type="primary" @click="showUnscannedDialog = false; showCamera = true">去扫码</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick } from 'vue'
import { ref, reactive, nextTick, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, CameraFilled, Close, Refresh, Select, Delete } from '@element-plus/icons-vue'
import { Scissor, CameraFilled } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { scanBarcode, createScrap } from '@/api/scrap'
import { scanBarcode, getScrapApprovals, executeScrapByRequest } from '@/api/scrap'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const hasPermission = userStore.hasPermission('scrap_create:operation')
const hasPermission = computed(() =>
userStore.role === 'SUPER_ADMIN' || userStore.hasPermission('scrap_execute')
)
// --- 状态定义 ---
const barcodeInput = ref('')
@ -154,14 +269,171 @@ const cartItems = ref<any[]>([])
const loading = ref(false)
const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// 已批准的报废申请单
const approvalRequests = ref<any[]>([])
const selectedRequest = ref<any>(null)
const requestsLoading = ref(false)
const showUnscannedDialog = ref(false)
const form = reactive({
reason: '',
remark: ''
reason: ''
})
// --- 核心扫码逻辑 ---
// --- 计算属性 ---
const selectedRequestId = computed({
get: () => selectedRequest.value?.id ?? null,
set: (val) => {
if (!val) {
selectedRequest.value = null
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
}
}
})
const plannedItems = computed<any[]>(() => selectedRequest.value?.items ?? [])
// 扫码进度
const scanTotalQty = computed(() =>
plannedItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_qty) || 0), 0)
)
const scanScannedQty = computed(() =>
cartItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_quantity) || 0), 0)
)
const scanScannedTypes = computed(() => cartItems.value.length)
const scanTotalTypes = computed(() => plannedItems.value.length)
// ============================================================
// ★ SKU 主键匹配(与后端 _match_key 完全同口径)
//
// SKU 在同一库存表内唯一、且不存在跨表重名,故以 SKU 作为扫码匹配的唯一标识。
// 个别历史库存行 SKU 为空,这类行回退为 source_table + stock_id 复合键;
// 前缀区分(sku: / row:)确保空 SKU 行绝不会与正常 SKU 串键。
// ============================================================
const normSku = (sku: any) => String(sku ?? '').trim()
const matchKey = (sourceTable: any, stockId: any, sku: any) => {
const s = normSku(sku)
return s ? `sku:${s}` : `row:${String(sourceTable)}#${String(stockId)}`
}
// 按匹配键找批准明细行
const findPlanByKey = (key: string) =>
plannedItems.value.find((p: any) => matchKey(p.source_table, p.stock_id, p.sku) === key)
// 某匹配键的批准总量(同一 SKU 在批准单中出现多行时累加)
const approvedQtyByKey = (key: string) =>
plannedItems.value
.filter((p: any) => matchKey(p.source_table, p.stock_id, p.sku) === key)
.reduce((sum: number, p: any) => sum + (Number(p.scrap_qty) || 0), 0)
// 某匹配键在购物车中的已扫总量
const scannedQtyByKey = (key: string) =>
cartItems.value
.filter((ci: any) => matchKey(ci.source_table, ci.id, ci.sku) === key)
.reduce((sum: number, ci: any) => sum + (Number(ci.scrap_quantity) || 0), 0)
// 展示用:计划行的已扫数量
const scannedQtyOf = (plan: any) =>
scannedQtyByKey(matchKey(plan.source_table, plan.stock_id, plan.sku))
// 购物车某行还能再报废多少(该 SKU 批准量 - 其余行已占用量,且不超当前可用库存)
const maxScrapFor = (row: any) => {
const key = matchKey(row.source_table, row.id, row.sku)
const approved = approvedQtyByKey(key)
const others = cartItems.value
.filter((ci: any) => ci !== row && matchKey(ci.source_table, ci.id, ci.sku) === key)
.reduce((sum: number, ci: any) => sum + (Number(ci.scrap_quantity) || 0), 0)
const avail = parseFloat(row.available_quantity) || 0
return Math.max(0, Math.min(approved - others, avail))
}
/**
* ★ 扫码校验(SKU 主键)
*
* 返回 null 表示放行;返回字符串表示拦截原因(调用方以红色 toast 提示并阻断入车)。
* · 扫码 SKU 不在批准明细中 → 拦截
* · 该 SKU 累计扫码量 + 本次数量 > 批准量 → 拦截
*/
const validateAgainstPlan = (item: any, addQty: number): string | null => {
const key = matchKey(item.source_table, item.id, item.sku)
const plan = findPlanByKey(key)
if (!plan) {
const s = normSku(item.sku)
return s
? `SKU【${s}】不在该报废申请单的批准明细中,禁止报废`
: `该物料【${item.name || ''}】不在申请单的批准明细中,禁止报废`
}
const approved = approvedQtyByKey(key)
const already = scannedQtyByKey(key)
if (already + addQty > approved) {
const label = normSku(plan.sku) || plan.name || ''
return `SKU【${label}】超出批准数量(批准: ${approved},已扫: ${already},本次: ${addQty})`
}
return null
}
// 未扫清单
const unscannedList = computed(() =>
plannedItems.value.map((plan: any) => {
const planQty = Number(plan.scrap_qty) || 0
const scannedQty = scannedQtyOf(plan)
return {
name: plan.name || '',
sku: plan.sku || '',
planQty,
scannedQty,
remaining: Math.max(0, planQty - scannedQty)
}
}).filter((item: any) => item.remaining > 0)
)
const unscannedCount = computed(() => unscannedList.value.length)
const sourceLabel = (st: string) =>
({ stock_buy: '采购件', stock_semi: '半成品', stock_product: '成品' } as any)[st] || st || '-'
// --- 加载已批准的报废申请单 ---
const loadApprovalRequests = async () => {
requestsLoading.value = true
try {
const res: any = await getScrapApprovals({ scope: 'executable', status: 1, page: 1, limit: 100 })
approvalRequests.value = res.data?.items || []
// 支持从审批页跳转时带 requestId 直接选中
const presetId = Number(route.query.requestId)
if (presetId) {
const hit = approvalRequests.value.find(r => r.id === presetId)
if (hit) handleRequestChange(presetId)
else ElMessage.warning('指定的报废申请单不可执行(可能已被执行或状态已变更)')
}
} catch (e) {
console.error('加载报废申请单失败', e)
ElMessage.error('加载报废申请单失败')
} finally {
requestsLoading.value = false
}
}
const handleRequestChange = (val: number | null) => {
if (!val) {
selectedRequest.value = null
form.reason = ''
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
form.reason = selectedRequest.value?.remark || ''
}
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
cartItems.value = []
barcodeInput.value = ''
}
// --- 扫码逻辑 ---
const onScanSuccess = (code: string) => {
if (!code) return
const trimCode = code.trim()
@ -184,48 +456,94 @@ const onScanSuccess = (code: string) => {
}
const handleManualInput = async () => {
if (!hasPermission.value) {
ElMessage.warning('无操作权限')
return
}
const code = barcodeInput.value.trim()
if (!code) return
if (!selectedRequest.value) {
ElMessage.warning('请先选择要执行的报废申请单')
return
}
try {
loading.value = true
// 查重
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
// 1. 购物车已有该 SKU → 累加(与批准明细同口径,按 SKU 去重)
const existIndex = cartItems.value.findIndex(
item => normSku(item.sku) === code || item.barcode === code || String(item.id) === code
)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
const maxQty = parseFloat(item.available_quantity)
if (item.quantity < maxQty) {
item.quantity++
ElMessage.success(`数量+1 (当前: ${item.quantity})`)
if (navigator.vibrate) navigator.vibrate(50)
} else {
ElMessage.warning(`库存不足 (余: ${maxQty})`)
// ★ 越权/超量拦截:红色错误提示并阻断扫码
const err = validateAgainstPlan(item, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const maxQty = maxScrapFor(item)
if (maxQty <= 0) {
ElMessage.error(`【${item.name}】已达批准数量上限,无法继续报废`)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
try {
await ElMessageBox.confirm(
`【${item.name} × ${item.spec_model || '-'}】已在清单中(当前已扫 ${item.scrap_quantity} 个)。\n\n确认再 +1 吗?`,
'重复扫码确认',
{ confirmButtonText: '确认 +1', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
barcodeInput.value = ''
return
}
item.scrap_quantity++
ElMessage.success(`数量+1 (当前: ${item.scrap_quantity})`)
if (navigator.vibrate) navigator.vibrate(50)
barcodeInput.value = ''
return
}
// 查库
const res = await scanBarcode(code)
// 2. 查库
const res: any = await scanBarcode(code)
if (res.code === 200 && res.data) {
const item = res.data
const availQty = parseFloat(item.available_quantity || 0)
if (availQty <= 0) {
ElMessage.warning(`库存不足 (余: ${availQty})`)
} else {
cartItems.value.push({
...item,
quantity: 1,
price: item.price || 0
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
// ★ 校验是否在批准明细内 / 是否超批准量
const err = validateAgainstPlan(item, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const availQty = parseFloat(item.available_quantity || 0)
if (availQty <= 0) {
ElMessage.error(`库存不足 (余: ${availQty})`)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
cartItems.value.push({
...item,
scrap_quantity: 1
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
} else {
ElMessage.error(res.msg || '未找到该物料')
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
}
} catch (error: any) {
if (error.response && error.response.status === 404) {
@ -233,6 +551,7 @@ const handleManualInput = async () => {
} else {
ElMessage.error('查询出错')
}
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
} finally {
loading.value = false
if (!showCamera.value) {
@ -242,251 +561,190 @@ const handleManualInput = async () => {
}
const removeFromCart = (index: number) => {
if (!hasPermission.value) {
ElMessage.warning('无操作权限')
return
}
cartItems.value.splice(index, 1)
}
const clearAll = () => {
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
if (!hasPermission.value) {
ElMessage.warning('无操作权限')
return
}
ElMessageBox.confirm('确定清空所有已扫物品吗?(保留申请单选择)', '提示', { type: 'warning' })
.then(() => {
cartItems.value = []
form.reason = ''
form.remark = ''
barcodeInput.value = ''
})
.catch(() => {})
}
// --- 提交逻辑 ---
// --- 提交执行 ---
const submitForm = async () => {
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
// 验证报废原因
if (!form.reason.trim()) {
ElMessage.error('请填写报废原因')
if (!hasPermission.value) {
ElMessage.warning('无操作权限')
return
}
if (!selectedRequest.value) return ElMessage.warning('请先选择要执行的报废申请单')
if (cartItems.value.length === 0) return ElMessage.warning('请先扫码添加报废物料')
// 检查报废数量
const invalidItem = cartItems.value.find(item => !item.quantity || item.quantity <= 0)
if (invalidItem) {
ElMessage.warning('请填写有效的报废数量')
return
// 逐项复核批准量
for (const item of cartItems.value) {
const err = validateAgainstPlan(item, 0)
if (err) return ElMessage.error(err)
if (!item.scrap_quantity || item.scrap_quantity <= 0) {
return ElMessage.warning('请填写有效的报废数量')
}
if (item.scrap_quantity > (parseFloat(item.available_quantity) || 0)) {
return ElMessage.error(`【${item.name}】报废数量超过当前可用库存`)
}
}
// 检查报废数量是否超过可用库存
const overstockItem = cartItems.value.find(item => item.quantity > parseFloat(item.available_quantity))
if (overstockItem) {
ElMessage.warning(`物料 ${overstockItem.sku} 报废数量超过可用库存`)
return
}
try {
await ElMessageBox.confirm(
`确认按实扫数量执行报废吗?\n申请单:${selectedRequest.value.request_no}\n共 ${cartItems.value.length} 项 / ${scanScannedQty.value} 件\n\n执行后不可撤销。`,
'执行确认',
{ confirmButtonText: '确认执行', cancelButtonText: '取消', type: 'warning' }
)
} catch { return }
try {
loading.value = true
const data = {
reason: form.reason,
remark: form.remark,
items: cartItems.value.map(item => ({
id: item.id,
sku: item.sku,
source_table: item.source_table,
quantity: item.quantity
}))
}
const items = cartItems.value.map(item => ({
source_table: item.source_table,
stock_id: item.id,
quantity: item.scrap_quantity,
sku: item.sku ? String(item.sku) : '',
name: item.name ? String(item.name) : ''
}))
const res = await createScrap(data)
if (res.code === 200) {
ElMessage.success('报废提交成功')
router.push('/scrap/index')
} else {
ElMessage.error(res.msg || '提交失败')
}
} catch (error: any) {
console.error(error)
ElMessage.error(error.response?.data?.msg || '提交失败')
await executeScrapByRequest(selectedRequest.value.id, items)
ElMessage.success('已执行报废并扣减库存')
router.push('/scrap/index')
} catch (err: any) {
// 拦截器已弹后端业务错误;无响应才算网络异常
if (!err?.response) ElMessage.error('网络异常,请重试')
} finally {
loading.value = false
}
}
onMounted(() => {
loadApprovalRequests()
})
</script>
<style scoped>
.app-container {
padding: 10px;
.app-container.mobile-optimized {
padding: 10px; max-width: 700px; margin: 0 auto;
}
.box-card {
max-width: 1200px;
margin: 0 auto;
}
.card-header { display: flex; justify-content: space-between; align-items: center; }
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
.card-header {
/* 模式条 */
.mode-switch-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px 16px;
background: #fef0f0;
border-radius: 8px;
border: 1px solid #fde2e2;
}
.mode-hint { color: #909399; font-size: 13px; }
.title-box {
/* 申请单选择 */
.approval-request-select { margin-bottom: 16px; }
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
/* 待执行清单 */
.planned-items-section {
margin-bottom: 16px;
padding: 12px;
background: #fef0f0;
border: 1px solid #fde2e2;
border-radius: 8px;
}
.planned-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.planned-title { font-weight: bold; font-size: 14px; color: #F56C6C; }
/* 扫码区 */
.scan-section { margin-bottom: 20px; }
.scan-progress {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 12px;
}
.progress-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: bold;
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
}
.scan-section {
margin-bottom: 20px;
}
.camera-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 180px;
background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
border: 2px dashed #409EFF;
border-radius: 8px;
cursor: pointer;
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
display: flex; flex-direction: column; justify-content: center; align-items: center;
color: #909399; margin-bottom: 10px; cursor: pointer;
transition: all 0.3s;
}
.camera-placeholder:active { background: #e6e8eb; }
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
.camera-placeholder:hover {
background: linear-gradient(135deg, #d9ecff 0%, #b3d8ff 100%);
transform: scale(1.01);
}
.input-box :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #dcdfe6 inset; }
.camera-placeholder .text {
margin-top: 10px;
color: #409EFF;
font-size: 14px;
}
.input-box {
margin-top: 15px;
}
.input-box :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px #dcdfe6 inset;
}
.input-box :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
.input-box :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #409EFF inset;
}
.cart-section {
margin-bottom: 20px;
}
.form-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.form-section :deep(.el-divider__text) {
font-weight: bold;
color: #F56C6C;
}
.bottom-actions {
display: flex;
justify-content: flex-end;
gap: 15px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.bottom-actions .el-button--large {
padding: 12px 30px;
font-size: 16px;
}
/* 全屏扫码样式 */
/* 全屏扫码 */
.fullscreen-scanner-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
z-index: 2000;
display: flex;
flex-direction: column;
top: 0; left: 0; width: 100vw; height: 100vh;
background: #000; z-index: 9999;
display: flex; flex-direction: column;
}
.scanner-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
height: 60px; display: flex; align-items: center; justify-content: space-between;
padding: 0 15px; background: rgba(0,0,0,0.6); color: #fff;
position: absolute; top: 0; width: 100%; z-index: 10;
}
.scanner-title {
font-size: 18px;
font-weight: bold;
}
.close-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: #fff;
}
.scanner-placeholder {
width: 40px;
}
.scanner-title { font-size: 16px; font-weight: bold; }
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
.scanner-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex: 1; width: 100%; position: relative;
display: flex; align-items: center; justify-content: center;
}
:deep(.qr-scanner-container) {
width: 100% !important; height: 100% !important; border-radius: 0 !important;
}
.scanner-footer {
padding: 20px;
text-align: center;
background: rgba(0, 0, 0, 0.8);
color: #fff;
position: absolute; bottom: 0; width: 100%;
padding: 20px; background: rgba(0,0,0,0.6); color: #fff;
text-align: center; z-index: 10;
}
.current-count { color: #F56C6C; font-weight: bold; margin-top: 5px; font-size: 16px; }
.scanner-footer p {
margin: 5px 0;
}
/* 表单与购物车 */
.cart-section { margin-bottom: 20px; }
.form-section { background: #fff; }
.form-section :deep(.el-divider__text) { font-weight: bold; color: #F56C6C; }
.current-count {
font-size: 16px;
font-weight: bold;
color: #F56C6C;
}
.bottom-actions { display: flex; justify-content: space-between; margin-top: 20px; }
.bottom-actions .el-button { width: 48%; }
@media (max-width: 768px) {
.app-container {
padding: 5px;
}
.title-box {
font-size: 16px;
}
.camera-placeholder {
height: 120px;
}
.bottom-actions {
flex-direction: column;
}
.bottom-actions .el-button {
width: 100%;
}
@media screen and (max-width: 768px) {
.title-box { font-size: 14px; }
.camera-placeholder { height: 100px; }
}
</style>

View File

@ -1,48 +1,162 @@
<template>
<div v-if="userStore.hasPermission('scrap_list')" class="app-container">
<div class="filter-container">
<el-input v-model="sku" placeholder="SKU" style="width: 200px" @keyup.enter="fetchData" />
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="margin-left: 10px; width: 240px"
/>
<el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</div>
<!-- ★ 高级搜索:与出库记录 / 借还记录统一版式 -->
<el-form :inline="true" class="filter-form" @submit.prevent>
<el-form-item label="搜索">
<el-input
v-model="listQuery.keyword"
placeholder="输入关键词"
style="width: 240px"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="listQuery.search_type" placeholder="搜索类型" style="width: 110px">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="操作人" value="name" />
<el-option label="SKU" value="sku" />
<el-option label="物料名称" value="material_name" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="报废日期">
<el-date-picker
v-model="listQuery.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
<el-popover
v-model:visible="advancedFilterVisible"
placement="bottom"
title="高级筛选"
width="600"
trigger="manual"
>
<template #reference>
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
高级筛选
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
</el-button>
</template>
<div class="advanced-filter">
<div
v-for="(condition, index) in advancedConditions"
:key="index"
class="condition-row"
>
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
</div>
<div style="margin-top:12px">
<el-button type="primary" link @click="addCondition">添加条件</el-button>
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
<el-button @click="resetAdvancedFilter">重置</el-button>
</div>
</div>
</el-popover>
</el-form-item>
</el-form>
<el-table
:data="list"
border
stripe
style="margin-top: 20px"
v-loading="loading"
:data="list"
border
v-loading="loading"
style="width: 100%; margin-top: 20px;"
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
>
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
<el-table-column label="物料名称" min-width="160">
<template #default="{ row }">
{{ row.material_name || '-' }}
<!-- ★ 展开行:报废明细 -->
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px; background: #fafafa;">
<h4 style="margin: 0 0 10px 0; color: #F56C6C;">报废明细 ({{ props.row.items?.length || 0 }} 项)</h4>
<el-table :data="props.row.items" border size="small">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="sku" label="SKU" width="150" show-overflow-tooltip />
<el-table-column prop="material_name" label="物料名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.material_name || '-' }}</template>
</el-table-column>
<el-table-column prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.spec_model || '-' }}</template>
</el-table-column>
<el-table-column label="库位" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.warehouse_location || '-' }}</template>
</el-table-column>
<el-table-column label="批次/序列号" width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.batch_number || '-' }}</template>
</el-table-column>
<el-table-column prop="quantity" label="报废数量" width="100" align="right" />
<el-table-column v-if="canViewLoss" label="损失金额" width="110" align="right">
<template #default="{ row }">
<span v-if="row.loss_amount !== null && row.loss_amount !== undefined" style="color:#F56C6C;">
¥{{ row.loss_amount.toFixed(2) }}
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason || '-' }}</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column label="规格型号" min-width="140">
<el-table-column label="报废单号" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
{{ row.spec_model || '-' }}
<span>{{ row.scrap_request_no }}</span>
<el-tag v-if="row.is_legacy" size="small" type="info" style="margin-left:6px;">历史</el-tag>
</template>
</el-table-column>
<el-table-column prop="quantity" label="报废数量" width="100" align="right" />
<el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip />
<el-table-column prop="operator_name" label="操作人" width="100" />
<el-table-column prop="operation_time" label="报废时间" width="160" />
<el-table-column label="损失金额" width="100" align="right">
<el-table-column prop="applicant_name" label="申请人" width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.applicant_name || '-' }}</template>
</el-table-column>
<el-table-column prop="operator_name" label="操作人" width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.operator_name || '-' }}</template>
</el-table-column>
<el-table-column label="报废时间" width="170" align="center">
<template #default="{ row }">
{{ row.total_loss ? `¥${row.total_loss}` : '-' }}
<span>{{ row.scrap_time ? row.scrap_time.substring(0, 16) : '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="approval_status" label="审批状态" width="100" align="center">
<el-table-column label="报废总数" width="100" align="right">
<template #default="{ row }">{{ row.total_quantity }}</template>
</el-table-column>
<el-table-column v-if="canViewLoss" label="总损失金额" width="130" align="right">
<template #default="{ row }">
<span v-if="row.total_loss !== null && row.total_loss !== undefined" style="color:#F56C6C; font-weight:bold; font-size:15px;">
¥{{ Number(row.total_loss).toFixed(2) }}
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="审批状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.approval_status)">
{{ getStatusText(row.approval_status) }}
@ -51,20 +165,21 @@
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next, total"
:total="total"
:page-size="pageSize"
:current-page="page"
@current-change="handlePage"
style="margin-top: 10px; text-align: right"
/>
<div style="margin-top: 20px; text-align: right;">
<el-pagination
background
layout="prev, pager, next, total"
:total="total"
:page-size="listQuery.pageSize"
:current-page="listQuery.page"
@current-change="handlePage"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { getScrapRecords } from '@/api/scrap'
import { useUserStore } from '@/stores/user'
@ -72,70 +187,165 @@ const userStore = useUserStore()
const list = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const sku = ref('')
const dateRange = ref<[string, string] | null>(null)
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const listQuery = reactive({
page: 1,
pageSize: 50,
keyword: '',
search_type: 'all',
dateRange: [] as string[],
advancedFilters: [] as any[],
})
// --- ★ 高级筛选 ---
const advancedFilterVisible = ref(false)
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
const appliedConditions = ref<any[]>([]) // 已生效的条件(用于按钮角标)
const fieldOptions = [
{ value: 'no', label: '单号' },
{ value: 'sku', label: 'SKU' },
{ value: 'material_name', label: '物料名称' },
{ value: 'operator', label: '操作人' },
]
const operatorOptions = [
{ value: 'contains', label: '包含' },
{ value: 'eq', label: '等于' },
{ value: 'not_contains', label: '不包含' },
{ value: 'ne', label: '不等于' },
]
const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' })
}
const removeCondition = (index: number) => {
advancedConditions.value.splice(index, 1)
}
const applyAdvancedFilter = () => {
// 过滤掉不完整的条件行
const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
listQuery.advancedFilters = valid
appliedConditions.value = valid
advancedFilterVisible.value = false
listQuery.page = 1
fetchData()
}
const resetAdvancedFilter = () => {
advancedConditions.value = [{ field: '', operator: '', value: '' }]
listQuery.advancedFilters = []
appliedConditions.value = []
advancedFilterVisible.value = false
listQuery.page = 1
fetchData()
}
// 损失金额可见性:与后端 scrap_list:loss_amount 权限一致
const canViewLoss = computed(() =>
userStore.role === 'SUPER_ADMIN' || userStore.hasPermission('scrap_list:loss_amount')
)
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: page.value,
pageSize: pageSize.value,
sku: sku.value
page: listQuery.page,
pageSize: listQuery.pageSize,
keyword: listQuery.keyword,
search_type: listQuery.search_type,
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
}
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
params.start_date = listQuery.dateRange[0]
params.end_date = listQuery.dateRange[1]
}
const res = await getScrapRecords(params)
list.value = res.data.list || []
total.value = res.data.total || 0
const res: any = await getScrapRecords(params)
list.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (e) {
console.error('加载报废记录失败', e)
} finally {
loading.value = false
}
}
const debouncedSearch = () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
listQuery.page = 1
fetchData()
}, 500)
}
const handleClearSearch = () => {
if (debounceTimer) clearTimeout(debounceTimer)
listQuery.page = 1
fetchData()
}
const handlePage = (val: number) => {
page.value = val
listQuery.page = val
fetchData()
}
const resetFilter = () => {
sku.value = ''
dateRange.value = null
page.value = 1
listQuery.keyword = ''
listQuery.search_type = 'all'
listQuery.dateRange = []
listQuery.advancedFilters = []
advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = []
listQuery.page = 1
fetchData()
}
// 报废台账写在执行/提交时,approval_status 的取值为这两个
const getStatusType = (status: string) => {
const map: Record<string, string> = {
executed: 'success',
approved: 'success',
rejected: 'danger',
pending: 'warning'
pending: 'warning',
}
return map[status] || 'info'
}
const getStatusText = (status: string) => {
const map: Record<string, string> = {
approved: '已审批',
executed: '已执行',
approved: '已报废',
rejected: '已拒绝',
pending: '待审批'
pending: '待审批',
}
return map[status] || status
return map[status] || status || '-'
}
onMounted(fetchData)
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
</script>
<style scoped>
.filter-container {
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
/* 高级筛选条件行 */
.condition-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
</style>

View File

@ -1,41 +1,89 @@
<template>
<div class="app-container">
<div class="filter-container">
<CompanySelector v-model="listQuery.company" @change="fetchData" style="margin-right: 10px;" />
<!-- ★ 高级搜索:与借还记录 / 报废记录统一版式 -->
<el-form :inline="true" class="filter-form" @submit.prevent>
<el-form-item>
<CompanySelector v-model="listQuery.company" @change="fetchData" />
</el-form-item>
<el-input
v-model="listQuery.keyword"
placeholder="请输入关键词"
style="width: 250px;"
class="filter-item"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="listQuery.search_type" placeholder="搜索类型" style="width: 110px" @change="debouncedSearch">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="姓名" value="name" />
<el-option label="SKU" value="sku" />
<el-option label="物料名称" value="material_name" />
<el-option label="规格型号" value="spec_model" />
</el-select>
</template>
</el-input>
<el-date-picker
v-model="listQuery.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
class="filter-item"
style="margin-left: 10px;"
value-format="YYYY-MM-DD"
/>
<el-button type="primary" class="filter-item" style="margin-left: 10px;" @click="fetchData">查询</el-button>
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="success" class="filter-item" @click="$router.push('/outbound/create')">新建出库</el-button>
</div>
<el-form-item label="搜索">
<el-input
v-model="listQuery.keyword"
placeholder="输入关键词"
style="width: 240px;"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="listQuery.search_type" placeholder="搜索类型" style="width: 110px" @change="handleSearchImmediate">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="姓名" value="name" />
<el-option label="SKU" value="sku" />
<el-option label="物料名称" value="material_name" />
<el-option label="规格型号" value="spec_model" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="出库日期">
<el-date-picker
v-model="listQuery.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
<el-popover
v-model:visible="advancedFilterVisible"
placement="bottom"
title="高级筛选"
width="600"
trigger="manual"
>
<template #reference>
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
高级筛选
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
</el-button>
</template>
<div class="advanced-filter">
<div
v-for="(condition, index) in advancedConditions"
:key="index"
class="condition-row"
>
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
</div>
<div style="margin-top:12px">
<el-button type="primary" link @click="addCondition">添加条件</el-button>
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
<el-button @click="resetAdvancedFilter">重置</el-button>
</div>
</div>
</el-popover>
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="success" @click="$router.push('/outbound/create')">新建出库</el-button>
</el-form-item>
</el-form>
<el-table
:data="list"
@ -220,16 +268,59 @@ const listQuery = reactive({
keyword: '',
search_type: 'all',
dateRange: [],
company: '' as string
company: '' as string,
advancedFilters: [] as any[],
})
// --- ★ 高级筛选 ---
const advancedFilterVisible = ref(false)
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
const appliedConditions = ref<any[]>([])
const fieldOptions = [
{ value: 'no', label: '单号' },
{ value: 'sku', label: 'SKU' },
{ value: 'material_name', label: '物料名称' },
{ value: 'operator', label: '操作人' },
]
const operatorOptions = [
{ value: 'contains', label: '包含' },
{ value: 'eq', label: '等于' },
{ value: 'not_contains', label: '不包含' },
{ value: 'ne', label: '不等于' },
]
const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' })
}
const removeCondition = (index: number) => {
advancedConditions.value.splice(index, 1)
}
const applyAdvancedFilter = () => {
const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
listQuery.advancedFilters = valid
appliedConditions.value = valid
advancedFilterVisible.value = false
listQuery.page = 1
fetchData()
}
const resetAdvancedFilter = () => {
advancedConditions.value = [{ field: '', operator: '', value: '' }]
listQuery.advancedFilters = []
appliedConditions.value = []
advancedFilterVisible.value = false
listQuery.page = 1
fetchData()
}
const fetchData = async () => {
loading.value = true
try {
const params = {
...listQuery,
start_date: listQuery.dateRange && listQuery.dateRange[0] ? listQuery.dateRange[0] : null,
end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null
end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null,
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
}
const res = await getOutboundList(params)
@ -247,6 +338,25 @@ const handlePageChange = (val: number) => {
fetchData()
}
// 立即查询(搜索类型切换用,取消防抖)
const handleSearchImmediate = () => {
if (debounceTimer) clearTimeout(debounceTimer)
listQuery.page = 1
fetchData()
}
const resetFilter = () => {
if (debounceTimer) clearTimeout(debounceTimer)
listQuery.keyword = ''
listQuery.search_type = 'all'
listQuery.dateRange = []
listQuery.advancedFilters = []
advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = []
listQuery.page = 1
fetchData()
}
const formatType = (type: string) => {
const map: any = {
'SALES': '销售出库',
@ -285,6 +395,24 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* ★ 统一高级搜索版式(与借还记录 / 报废记录一致) */
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
/* 高级筛选条件行 */
.condition-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.signature-cell {
display: flex;
justify-content: center;

View File

@ -1,33 +1,91 @@
<template>
<div v-if="userStore.hasPermission('op_records')" class="app-container">
<div class="filter-container">
<el-radio-group v-model="status" @change="fetchData" style="margin-right: 20px">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="borrowed">未归还</el-radio-button>
<el-radio-button label="returned">已归还</el-radio-button>
</el-radio-group>
<!-- ★ 高级搜索:与出库记录 / 报废记录统一版式 -->
<el-form :inline="true" class="filter-form" @submit.prevent>
<el-form-item>
<el-radio-group v-model="status" @change="handleSearchImmediate">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="borrowed">未归还</el-radio-button>
<el-radio-button label="returned">已归还</el-radio-button>
</el-radio-group>
</el-form-item>
<el-input
v-model="keyword"
placeholder="请输入关键词"
style="width: 250px"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="searchType" placeholder="类型" style="width: 110px" @change="debouncedSearch">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="借用人" value="name" />
<el-option label="SKU" value="sku" />
<el-option label="物料名称" value="material_name" />
<el-option label="规格型号" value="spec_model" />
</el-select>
</template>
</el-input>
<el-button type="primary" @click="fetchData">查询</el-button>
</div>
<el-form-item label="搜索">
<el-input
v-model="keyword"
placeholder="输入关键词"
style="width: 240px"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="searchType" placeholder="类型" style="width: 110px" @change="handleSearchImmediate">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="借用人" value="name" />
<el-option label="SKU" value="sku" />
<el-option label="物料名称" value="material_name" />
<el-option label="规格型号" value="spec_model" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="借出日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
<el-popover
v-model:visible="advancedFilterVisible"
placement="bottom"
title="高级筛选"
width="600"
trigger="manual"
>
<template #reference>
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
高级筛选
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
</el-button>
</template>
<div class="advanced-filter">
<div
v-for="(condition, index) in advancedConditions"
:key="index"
class="condition-row"
>
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
</div>
<div style="margin-top:12px">
<el-button type="primary" link @click="addCondition">添加条件</el-button>
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
<el-button @click="resetAdvancedFilter">重置</el-button>
</div>
</div>
</el-popover>
</el-form-item>
</el-form>
<el-table
:data="list"
@ -262,9 +320,51 @@ const total = ref(0)
const status = ref('borrowed')
const keyword = ref('')
const searchType = ref('all')
const dateRange = ref<string[]>([])
const page = ref(1)
const loading = ref(false)
// --- ★ 高级筛选 ---
const advancedFilterVisible = ref(false)
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
const appliedConditions = ref<any[]>([])
const advancedFilters = ref<any[]>([])
const fieldOptions = [
{ value: 'no', label: '单号' },
{ value: 'sku', label: 'SKU' },
{ value: 'material_name', label: '物料名称' },
{ value: 'borrower_name', label: '操作人' },
]
const operatorOptions = [
{ value: 'contains', label: '包含' },
{ value: 'eq', label: '等于' },
{ value: 'not_contains', label: '不包含' },
{ value: 'ne', label: '不等于' },
]
const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' })
}
const removeCondition = (index: number) => {
advancedConditions.value.splice(index, 1)
}
const applyAdvancedFilter = () => {
const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
advancedFilters.value = valid
appliedConditions.value = valid
advancedFilterVisible.value = false
page.value = 1
fetchData()
}
const resetAdvancedFilter = () => {
advancedConditions.value = [{ field: '', operator: '', value: '' }]
advancedFilters.value = []
appliedConditions.value = []
advancedFilterVisible.value = false
page.value = 1
fetchData()
}
// ★ 借库报废相关
const canScrap = computed(() =>
userStore.role === 'SUPER_ADMIN' ||
@ -329,15 +429,22 @@ const confirmScrap = async () => {
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: page.value,
status: status.value,
keyword: keyword.value,
search_type: searchType.value,
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
advancedFilters: JSON.stringify(advancedFilters.value || []),
}
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const res = await request({
url: '/v1/transactions/records',
method: 'get',
params: {
page: page.value,
status: status.value,
keyword: keyword.value,
search_type: searchType.value
}
params
})
// ★ 按 borrow_no 分组聚合为主子表结构
@ -371,6 +478,26 @@ const handlePage = (val: number) => {
fetchData()
}
// 立即查询(状态/类型切换用,取消防抖)
const handleSearchImmediate = () => {
if (debounceTimer) clearTimeout(debounceTimer)
page.value = 1
fetchData()
}
const resetFilter = () => {
if (debounceTimer) clearTimeout(debounceTimer)
status.value = 'borrowed'
keyword.value = ''
searchType.value = 'all'
dateRange.value = []
advancedFilters.value = []
advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = []
page.value = 1
fetchData()
}
// ★ 新增:格式化预计归还时间及倒计时逻辑(交通灯预警系统)
const formatExpectedTime = (timeStr: string) => {
if (!timeStr) return { text: '无限期', diffText: '', style: {} }
@ -418,5 +545,22 @@ onBeforeUnmount(() => {
})
</script>
<style>
<style scoped>
/* ★ 统一高级搜索版式(与出库记录 / 报废记录一致) */
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
/* 高级筛选条件行 */
.condition-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
</style>