MM 模块库存管理开发:出入库增强、批次管理与库存盘点功能开发
博客简介:覆盖库存管理核心开发场景,讲解 MIGO 入库/出库增强 BAdI 开发、批次自动确定逻辑开发、循环盘点/动态盘点自定义程序开发、库存预警报表开发的核心逻辑,附"入库单自动校验采购订单匹配校验""批次效期自动预警""库龄分析报表"的完整代码,保障库存数据准确性与出入库流程合规性。
"采购订单、物料主数据、采购申请增强你都做过了,MM 模块最后一块拼图------库存管理(MIGO 出入库、批次效期、盘点预警)------本篇用三个贯穿场景完整演示,附 MIGO BAdI 校验代码、批次效期预警程序、库龄分析报表,让你把 MM 模块的增强开发做全。"
本篇覆盖库存管理核心开发场景,讲解 MIGO 入库/出库增强 BAdI 开发、批次自动确定逻辑开发、循环盘点/动态盘点自定义程序开发、库存预警报表开发的核心逻辑,附完整的出入库匹配校验、批次效期预警、库龄分析报表代码。
📖 写在前面
本篇定位
库存管理(Inventory Management)是 MM 模块操作频率最高的功能------几乎每个物料的生命周期都要经过"入库→存储→出库"三个阶段。库存管理的增强需求集中在:MIGO 操作的合规校验、批次效期管控、库存数据质量保障。本篇聚焦三个高频场景,所有代码基于 SAP NetWeaver 7.51。
本篇适合谁读
- 需要在 MM 模块做 MIGO/批次/盘点增强的开发者
- 负责库存数据质量的业务分析师
- 想完善 MM 模块增强体系的架构师
贯穿场景概览
#mermaid-svg-J9pGKqRzVwFvK61n{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-J9pGKqRzVwFvK61n .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-J9pGKqRzVwFvK61n .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-J9pGKqRzVwFvK61n .error-icon{fill:#552222;}#mermaid-svg-J9pGKqRzVwFvK61n .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-J9pGKqRzVwFvK61n .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-J9pGKqRzVwFvK61n .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-J9pGKqRzVwFvK61n .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-J9pGKqRzVwFvK61n .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-J9pGKqRzVwFvK61n .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-J9pGKqRzVwFvK61n .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-J9pGKqRzVwFvK61n .marker{fill:#333333;stroke:#333333;}#mermaid-svg-J9pGKqRzVwFvK61n .marker.cross{stroke:#333333;}#mermaid-svg-J9pGKqRzVwFvK61n svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-J9pGKqRzVwFvK61n p{margin:0;}#mermaid-svg-J9pGKqRzVwFvK61n .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-J9pGKqRzVwFvK61n .cluster-label text{fill:#333;}#mermaid-svg-J9pGKqRzVwFvK61n .cluster-label span{color:#333;}#mermaid-svg-J9pGKqRzVwFvK61n .cluster-label span p{background-color:transparent;}#mermaid-svg-J9pGKqRzVwFvK61n .label text,#mermaid-svg-J9pGKqRzVwFvK61n span{fill:#333;color:#333;}#mermaid-svg-J9pGKqRzVwFvK61n .node rect,#mermaid-svg-J9pGKqRzVwFvK61n .node circle,#mermaid-svg-J9pGKqRzVwFvK61n .node ellipse,#mermaid-svg-J9pGKqRzVwFvK61n .node polygon,#mermaid-svg-J9pGKqRzVwFvK61n .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-J9pGKqRzVwFvK61n .rough-node .label text,#mermaid-svg-J9pGKqRzVwFvK61n .node .label text,#mermaid-svg-J9pGKqRzVwFvK61n .image-shape .label,#mermaid-svg-J9pGKqRzVwFvK61n .icon-shape .label{text-anchor:middle;}#mermaid-svg-J9pGKqRzVwFvK61n .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-J9pGKqRzVwFvK61n .rough-node .label,#mermaid-svg-J9pGKqRzVwFvK61n .node .label,#mermaid-svg-J9pGKqRzVwFvK61n .image-shape .label,#mermaid-svg-J9pGKqRzVwFvK61n .icon-shape .label{text-align:center;}#mermaid-svg-J9pGKqRzVwFvK61n .node.clickable{cursor:pointer;}#mermaid-svg-J9pGKqRzVwFvK61n .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-J9pGKqRzVwFvK61n .arrowheadPath{fill:#333333;}#mermaid-svg-J9pGKqRzVwFvK61n .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-J9pGKqRzVwFvK61n .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-J9pGKqRzVwFvK61n .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J9pGKqRzVwFvK61n .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-J9pGKqRzVwFvK61n .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J9pGKqRzVwFvK61n .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-J9pGKqRzVwFvK61n .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-J9pGKqRzVwFvK61n .cluster text{fill:#333;}#mermaid-svg-J9pGKqRzVwFvK61n .cluster span{color:#333;}#mermaid-svg-J9pGKqRzVwFvK61n div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-J9pGKqRzVwFvK61n .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-J9pGKqRzVwFvK61n rect.text{fill:none;stroke-width:0;}#mermaid-svg-J9pGKqRzVwFvK61n .icon-shape,#mermaid-svg-J9pGKqRzVwFvK61n .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J9pGKqRzVwFvK61n .icon-shape p,#mermaid-svg-J9pGKqRzVwFvK61n .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-J9pGKqRzVwFvK61n .icon-shape .label rect,#mermaid-svg-J9pGKqRzVwFvK61n .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J9pGKqRzVwFvK61n .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-J9pGKqRzVwFvK61n .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-J9pGKqRzVwFvK61n :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 库存管理增强三大场景
场景1:MIGO 出入库增强校验
场景2:批次效期预警 + 自动确定
场景3:库存盘点 + 库龄分析
MIGO_GOODS_MOVEMENT_POSTING
入库 PO 匹配 / 出库库存 / 容差 / 效期
定时 Job + BADI_GOITEM
FEFO 先到期先出 / 效期预警
自定义盘点程序 + ALV
30/60/90/180/365 天分级
版本说明:本篇内容基于 SAP NetWeaver 7.51,MIGO_GOODS_MOVEMENT_POSTING BAdI 在 ECC 6.0+ 版本中均可用。
一、库存管理增强架构
1.1 核心增强点
| 增强类型 | 增强名称 | 触发时机 | 适用事务码 | 推荐场景 |
|---|---|---|---|---|
| BAdI | MIGO_GOODS_MOVEMENT_POSTING | 过账前最后一道校验 | MIGO | 出入库核心校验 |
| BAdI | MB_MIGO_BADI | MIGO 界面级增强 | MIGO | 自定义界面逻辑、子屏幕 |
| BAdI | BADI_GOITEM | MIGO 物料明细级别 | MIGO | 批次自动确定、效期检查 |
| 隐式增强 | 函数/方法首尾 | 函数首尾自动生成 | MIGO | BAdI 没覆盖的补充 |
推荐优先用 MIGO_GOODS_MOVEMENT_POSTING:功能最完善、参数最全、覆盖所有移动类型。
1.2 MIGO_GOODS_MOVEMENT_POSTING 接口方法
⚠️ 技术要点 :该 BAdI 的接口方法为
CHECK(在 ECC 6.0 中可能显示为IF_EX_MIGO_GOODS_MOVEMENT_PO~CHECK),传入IS_GOHEAD(抬头)和IT_GOITEM(行项目),通过CV_DONT_POST阻止过账。
| 参数名 | 方向 | 类型 | 说明 |
|---|---|---|---|
| IS_GOHEAD | IMPORTING | GOHEAD | 抬头数据(移动类型、过账日期、工厂、库存地点) |
| IT_GOITEM | IMPORTING | GOITEM_T | 行项目表(物料号、数量、批次、供应商、PO 号) |
| CT_MSEG | CHANGING | MSEG_T | 会计凭证行项目(财务科目自动派生) |
| CV_DONT_POST | CHANGING | FLAG | 阻止过账标志('X' = 阻止过账) |
1.3 库存管理核心表
| 表名 | 说明 | 关键字段 |
|---|---|---|
| MARD | 存储位置库存 | MATNR、WERKS、LABST(非限制库存)、INSME(质检)、SPEME(冻结) |
| MBEW | 会计评估库存 | MATNR、WERKS、VERPR(移动均价)、STPRS(标准价) |
| CHDO | 批次主数据 | MATNR、CHARG、VERBM(可用)、VFDAT(效期)、CLABS(可用量) |
| MKPF | 物料凭证抬头 | MBLNR、JAHR、BUDAT、USNAM、CPUTM |
| MSEG | 物料凭证行 | MBLNR、MATNR、CHARG、SHKZG(贷/借)、MENGE、EBELN |
| T001L | 工厂/存储位置 | WERKS、LGORT |
二、MIGO 入库/出库增强校验
2.1 配置表设计
出入库校验规则表:
表名:ZT_STOCK_VALIDATE_RULE
MANDT TYPE MANDT " 客户端
BUKRS TYPE BUKRS " 公司代码
WERKS TYPE WERKS_D " 工厂
LGORT TYPE LGORT_D " 存储位置(*=全部)
MATKL TYPE MATKL " 物料组(*=全部)
BWART TYPE BWART " 移动类型(101/102/201/202/261/...)
KIND TYPE CHAR2 " 规则类型:PO_CHECK/STOCK_CHECK/BATCH_CHECK
ACTIVE TYPE FLAG " 是否激活
PRIORITY TYPE NUMC2 " 优先级
容差配置表:
表名:ZT_STOCK_TOLERANCE
MANDT TYPE MANDT " 客户端
MATKL TYPE MATKL " 物料组(*=全部)
BWART TYPE BWART " 移动类型
TOL_PCT TYPE DEC3_2 " 容差百分比(如 110% = 10)
TOL_QTY TYPE QUAN " 容差数量
CHECK_MODE TYPE CHAR1 " S=百分比 / Q=数量 / B=两者
2.2 BAdI 实现代码(完整)
⚠️ 关键修正 :BAdI 接口名
IF_EX_MIGO_GOODS_MOVEMENT_PO,方法名CHECK(不是check)。IT_GOITEM是行项目内表,直接传入。
abap
"=======================================================================
" BAdI 实现:ZIMPL_MIGO_VALIDATE
" BAdI:MIGO_GOODS_MOVEMENT_POSTING
" 功能:入库 PO 匹配 + 出库库存检查 + 批次效期 + 超量容差
" 创建日期:2026.09.16
"=======================================================================
CLASS cl_impl_migo_validate DEFINITION PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
INTERFACES if_ex_migo_goods_movement_po.
PRIVATE SECTION.
METHODS validate_goods_receipt
IMPORTING iv_werks TYPE werks_d
iv_bukrs TYPE bukrs
iv_bwart TYPE bwart
it_item TYPE goitem_t
CHANGING cv_dont_post TYPE flag.
METHODS validate_goods_issue
IMPORTING iv_werks TYPE werks_d
iv_bwart TYPE bwart
it_item TYPE goitem_t
CHANGING cv_dont_post TYPE flag.
METHODS validate_batch_expiry
IMPORTING iv_bwart TYPE bwart
it_item TYPE goitem_t
CHANGING cv_dont_post TYPE flag.
ENDCLASS.
CLASS cl_impl_migo_validate IMPLEMENTATION.
METHOD if_ex_migo_goods_movement_po~check.
DATA: lv_bwart TYPE bwart,
lv_werks TYPE werks_d,
lv_lgort TYPE lgort_d,
lv_bukrs TYPE bukrs.
lv_bwart = is_gohead-bwart.
lv_werks = is_gohead-werks.
lv_lgort = is_gohead-lgort.
lv_bukrs = is_gohead-bukrs.
" 步骤1:入库校验(101/103/105)
IF lv_bwart IN ('101', '103', '105').
me->validate_goods_receipt(
EXPORTING iv_werks = lv_werks
iv_bukrs = lv_bukrs
iv_bwart = lv_bwart
it_item = it_goitem
CHANGING cv_dont_post = cv_dont_post ).
ENDIF.
" 步骤2:出库校验(201/202/261/262/541/542)
IF lv_bwart IN ('201', '202', '261', '262', '541', '542').
me->validate_goods_issue(
EXPORTING iv_werks = lv_werks
iv_bwart = lv_bwart
it_item = it_goitem
CHANGING cv_dont_post = cv_dont_post ).
ENDIF.
" 步骤3:批次效期检查(涉及批次的移动类型)
IF lv_bwart IN ('101', '102', '201', '261', '301', '302', '311', '312').
me->validate_batch_expiry(
EXPORTING iv_bwart = lv_bwart
it_item = it_goitem
CHANGING cv_dont_post = cv_dont_post ).
ENDIF.
ENDMETHOD.
METHOD validate_goods_receipt.
DATA: ls_item TYPE goitem,
lv_po_menge TYPE menge_d,
lv_tol_pct TYPE dec3_2,
lv_tol_check TYPE dec3_2.
LOOP AT it_item INTO ls_item.
IF ls_item-ebeln IS INITIAL.
CONTINUE.
ENDIF.
" 查询 PO 行项目原数量
SELECT SINGLE menge INTO @lv_po_menge
FROM ekpo
WHERE ebeln = @ls_item-ebeln
AND ebelp = @ls_item-ebelp.
IF sy-subrc <> 0.
MESSAGE e001(zmm_stock_enh) WITH ls_item-ebeln ls_item-ebelp.
cv_dont_post = 'X'.
CONTINUE.
ENDIF.
" 从容差配置表读取
SELECT SINGLE tol_pct INTO @lv_tol_pct
FROM zt_stock_tolerance
WHERE matkl = @ls_item-matkl
AND bwart = @iv_bwart.
IF sy-subrc <> 0.
lv_tol_pct = 10.
ENDIF.
lv_tol_check = lv_po_menge * ( 100 + lv_tol_pct ) / 100.
IF ls_item-erfmg > lv_tol_check.
MESSAGE e002(zmm_stock_enh)
WITH ls_item-ebeln lv_po_menge lv_tol_pct ls_item-erfmg.
cv_dont_post = 'X'.
ELSEIF ls_item-erfmg > lv_po_menge.
MESSAGE w003(zmm_stock_enh)
WITH ls_item-ebeln lv_po_menge ls_item-erfmg.
ENDIF.
ENDLOOP.
ENDMETHOD.
METHOD validate_goods_issue.
DATA: ls_item TYPE goitem,
lt_matnr TYPE TABLE OF matnr.
" 收集物料号
LOOP AT it_item INTO ls_item.
APPEND ls_item-matnr TO lt_matnr.
ENDLOOP.
SORT lt_matnr.
DELETE ADJACENT DUPLICATES FROM lt_matnr.
IF lt_matnr IS INITIAL.
RETURN.
ENDIF.
" 批量查询库存
SELECT matnr, werks, lgort, labst, insme, speme
INTO TABLE @DATA(lt_mard)
FROM mard
FOR ALL ENTRIES IN @lt_matnr
WHERE matnr = @lt_matnr
AND werks = @iv_werks.
LOOP AT it_item INTO ls_item.
DATA: ls_mard TYPE mard.
READ TABLE lt_mard INTO ls_mard
WITH KEY matnr = ls_item-matnr
werks = iv_werks
lgort = ls_item-lgort.
DATA(lv_available) = COND meinsm(
WHEN sy-subrc = 0
THEN ls_mard-labst + ls_mard-insme - ls_mard-speme
ELSE 0 ).
IF lv_available < ls_item-erfmg.
DATA(lv_short) = ls_item-erfmg - lv_available.
MESSAGE e005(zmm_stock_enh)
WITH ls_item-matnr ls_item-lgort ls_item-erfmg lv_available lv_short.
cv_dont_post = 'X'.
ENDIF.
ENDLOOP.
ENDMETHOD.
METHOD validate_batch_expiry.
DATA: ls_item TYPE goitem,
lv_today TYPE datum,
lv_30days TYPE datum.
lv_today = sy-datum.
lv_30days = sy-datum + 30.
" 收集批次号
DATA: lt_batch TYPE TABLE OF charg_d.
LOOP AT it_item INTO ls_item.
IF ls_item-charg IS NOT INITIAL.
APPEND ls_item-charg TO lt_batch.
ENDIF.
ENDLOOP.
SORT lt_batch.
DELETE ADJACENT DUPLICATES FROM lt_batch.
IF lt_batch IS INITIAL.
RETURN.
ENDIF.
" 批量查询批次效期
SELECT matnr, charg, vfdat, clabs
INTO TABLE @DATA(lt_chdo)
FROM chdo
FOR ALL ENTRIES IN @lt_batch
WHERE charg = @lt_batch-table_line.
LOOP AT it_item INTO ls_item.
IF ls_item-charg IS INITIAL.
CONTINUE.
ENDIF.
DATA: ls_chdo TYPE chdo.
READ TABLE lt_chdo INTO ls_chdo
WITH KEY matnr = ls_item-matnr
charg = ls_item-charg.
IF sy-subrc <> 0 OR ls_chdo-vfdat IS INITIAL.
CONTINUE.
ENDIF.
IF ls_chdo-vfdat < lv_today.
IF iv_bwart IN ('201', '202', '261', '541', '542').
MESSAGE e006(zmm_stock_enh)
WITH ls_item-matnr ls_item-charg ls_chdo-vfdat.
cv_dont_post = 'X'.
ELSE.
MESSAGE w007(zmm_stock_enh)
WITH ls_item-matnr ls_item-charg ls_chdo-vfdat.
ENDIF.
ELSEIF ls_chdo-vfdat <= lv_30days.
MESSAGE w008(zmm_stock_enh)
WITH ls_item-matnr ls_item-charg ls_chdo-vfdat.
ENDIF.
ENDLOOP.
ENDMETHOD.
ENDCLASS.
2.3 消息类配置(SE91)
消息类:ZMM_STOCK_ENH
001:采购订单 &1 行项目 &2 不存在
002:入库超量!&1 订单数量 &2 容差 &3% 实际 &4
003:入库数量 &3 超过订单数量 &2(在容差 &3% 内)
004:存储位置 &2 不存在(工厂 &1)
005:库存不足!物料 &1 位置 &2 需要 &3 可用 &4 短缺 &5
006:批次 &2 已过期(效期 &3),不允许出库!
007:批次 &2 已过期(效期 &3),请确认入库是否正确
008:批次 &2 效期 &3,30 天内到期请注意
三、批次自动确定与效期预警
3.1 MIGO 出库批次自动确定(FEFO 规则)
FEFO = First Expired, First Out(先到期先出)。在 MIGO 出库时自动选择效期最早的批次。
abap
"=======================================================================
" BAdI 实现:ZIMPL_BATCH_AUTO_DETERMINE
" BAdI:BADI_GOITEM
" 功能:出库时自动按 FEFO 规则选择批次
" 触发:MIGO 出库操作时,SAP 标准批次确定失败时调用
"=======================================================================
CLASS cl_impl_batch_auto_determine IMPLEMENTATION.
METHOD if_ex_badi_goitem~determine_batch.
DATA: lv_matnr TYPE matnr,
lv_werks TYPE werks_d,
lv_lgort TYPE lgort_d,
lv_need TYPE menge_d,
lv_bwart TYPE bwart.
lv_matnr = is_mseg-matnr.
lv_werks = is_mseg-werks.
lv_lgort = is_mseg-lgort.
lv_need = is_mseg-menge.
lv_bwart = is_mseg-bwart.
" 只在出库场景自动确定批次
IF lv_bwart NOT IN ('201', '202', '261', '541', '542', '311', '312').
RETURN.
ENDIF.
" 查询该物料所有可用批次(按效期升序)
SELECT chdo~matnr, chdo~charg, chdo~vfdat, chdo~clabs, mard~lgort
INTO TABLE @DATA(lt_batch)
FROM chdo
INNER JOIN mard ON mard~matnr = chdo~matnr
WHERE chdo~matnr = @lv_matnr
AND chdo~clabs > 0
AND chdo~verbm = 'X'
AND mard~werks = @lv_werks
AND mard~lgort = @lv_lgort
ORDER BY chdo~vfdat ASCENDING.
IF lt_batch IS INITIAL.
cv_charg = space.
RETURN.
ENDIF.
" 选择效期最早的、库存足够的批次
DATA(lv_remaining) = lv_need.
LOOP AT lt_batch INTO DATA(ls_batch).
IF ls_batch-clabs >= lv_remaining.
cv_charg = ls_batch-charg.
RETURN.
ELSE.
lv_remaining = lv_remaining - ls_batch-clabs.
ENDIF.
ENDLOOP.
cv_charg = space.
ENDMETHOD.
ENDCLASS.
3.2 批次效期预警程序
abap
"=======================================================================
" 后台 Job 程序:ZMM_BATCH_EXPIRY_ALERT
" 功能:扫描所有批次效期,30 天内到期 → 预警;已过期 → 锁定
" 定时执行:每天凌晨 1 点
"=======================================================================
REPORT zmm_batch_expiry_alert.
SELECTION-SCREEN BEGIN OF BLOCK b1 WITH FRAME TITLE text-001.
PARAMETERS: p_days TYPE i DEFAULT 30,
p_test AS CHECKBOX,
p_werks TYPE werks_d OBLIGATORY DEFAULT '1000'.
SELECTION-SCREEN END OF BLOCK b1.
DATA: lt_batch TYPE TABLE OF zt_batch_effective,
lt_expired TYPE TABLE OF zt_batch_effective,
lt_alert TYPE TABLE OF zt_batch_effective,
lt_results TYPE TABLE OF zt_expiry_result,
lv_today TYPE datum,
lv_alert TYPE datum.
START-OF-SELECTION.
lv_today = sy-datum.
lv_alert = sy-datum + p_days.
" 步骤1:查询所有效期 30 天内到期或已过期的批次
SELECT chdo~matnr, chdo~charg, chdo~vfdat, chdo~clabs, chdo~verbm, mara~matkl
INTO TABLE @lt_batch
FROM chdo
INNER JOIN mara ON mara~matnr = chdo~matnr
WHERE chdo~vfdat <= @lv_alert
AND chdo~vfdat IS NOT NULL
AND chdo~verbm = 'X'.
IF lt_batch IS INITIAL.
WRITE: / '没有效期即将到期的批次'.
STOP.
ENDIF.
" 步骤2:分类
LOOP AT lt_batch INTO DATA(ls_batch).
IF ls_batch-vfdat < lv_today.
APPEND ls_batch TO lt_expired.
ELSE.
APPEND ls_batch TO lt_alert.
ENDIF.
ENDLOOP.
" 步骤3:处理已过期批次
IF lt_expired IS NOT INITIAL AND p_test <> 'X'.
PERFORM lock_expired_batches USING lt_expired CHANGING lt_results.
ENDIF.
" 步骤4:处理即将到期批次(发预警邮件)
IF lt_alert IS NOT INITIAL.
PERFORM send_alert_email USING lt_alert.
ENDIF.
" 步骤5:输出汇总
PERFORM output_summary USING lt_expired lt_alert lt_results p_test.
"=======================================================================
" 子程序 1:锁定已过期批次
"=======================================================================
FORM lock_expired_batches
USING it_expired TYPE TABLE OF zt_batch_effective
CHANGING ct_results TYPE TABLE OF zt_expiry_result.
LOOP AT it_expired INTO DATA(ls_exp).
DATA(lv_guid) = cl_system_uuid=>create_uuid_c32_static( ).
UPDATE chdo
SET verbm = ''
WHERE matnr = @ls_exp-matnr
AND charg = @ls_exp-charg.
APPEND VALUE #(
guid = lv_guid
matnr = ls_exp-matnr
charg = ls_exp-charg
vfdat = ls_exp-vfdat
status = 'LOCKED'
msg = '批次已锁定(效期过期)'
ts = timestampl_current( ) ) TO ct_results.
ENDLOOP.
COMMIT WORK.
ENDFORM.
"=======================================================================
" 子程序 2:发预警邮件
"=======================================================================
FORM send_alert_email
USING it_alert TYPE TABLE OF zt_batch_effective.
DATA: lv_email_body TYPE string.
lv_email_body =
|<html><body><h3>批次效期预警({ p_days } 天内到期)</h3>|
&& |<table border="1"><tr>|
&& |<th>物料号</th><th>批次</th><th>效期</th><th>剩余天数</th><th>数量</th>|
&& |</tr>|.
LOOP AT it_alert INTO DATA(ls_alert).
DATA(lv_remain) = ls_alert-vfdat - sy-datum.
lv_email_body = lv_email_body
&& |<tr><td>{ ls_alert-matnr }</td><td>{ ls_alert-charg }</td>|
&& |<td>{ ls_alert-vfdat }</td><td>{ lv_remain }天</td>|
&& |<td>{ ls_alert-clabs }</td></tr>|.
ENDLOOP.
lv_email_body = lv_email_body && |</table></body></html>|.
TRY.
DATA(lo_message) = cl_document_bcs=>create_document(
i_type = 'HTML'
i_text = lv_email_body
i_subject = |【预警】批次效期 { p_days } 天内到期 - { sy-datum }| ).
DATA(lo_sender) = cl_sapuser_bcs=>create( sy-uname ).
DATA(lo_recipient) = cl_sapuser_bcs=>create( sy-uname ).
DATA(lo_bcs) = cl_bcs=>create( ).
lo_bcs->set_document( lo_message ).
lo_bcs->set_sender( lo_sender ).
lo_bcs->add_recipient( lo_recipient ).
IF lo_bcs->send( ) = 'X'.
WRITE: / '预警邮件发送成功'.
ENDIF.
CATCH cx_bcs INTO DATA(lx_bcs).
WRITE: / |邮件发送异常:{ lx_bcs->get_text( ) }|.
ENDTRY.
ENDFORM.
"=======================================================================
" 子程序 3:输出汇总
"=======================================================================
FORM output_summary
USING it_expired TYPE TABLE OF zt_batch_effective
it_alert TYPE TABLE OF zt_batch_effective
it_results TYPE TABLE OF zt_expiry_result
iv_test TYPE flag.
WRITE: / '='(60).
WRITE: / |批次效期预警扫描完成|.
WRITE: / '='(60).
WRITE: / |工厂:{ p_werks }|.
WRITE: / |预警天数:{ p_days }|.
WRITE: / |扫描时间:{ timestampl_current( ) }|.
WRITE: / '-'(60).
WRITE: / |已过期批次:{ lines( it_expired ) } 个|.
WRITE: / |即将到期批次:{ lines( it_alert ) } 个|.
IF iv_test = 'X'.
WRITE: / |【测试模式】未锁定任何批次|.
ELSE.
WRITE: / |已锁定批次:{ lines( it_results ) } 个|.
ENDIF.
WRITE: / '='(60).
ENDFORM.
四、库龄分析与库存盘点报表
4.1 库龄分析报表
abap
"=======================================================================
" 报表程序:ZMM_STOCK_AGEING
" 功能:库存库龄分析(30/60/90/180/365 天分段)
" 数据源:MARD(库存)+ MKPF/MSEG(最后一次出入库日期)
"=======================================================================
REPORT zmm_stock_ageing.
SELECTION-SCREEN BEGIN OF BLOCK b1 WITH FRAME TITLE text-001.
PARAMETERS: p_werks TYPE werks_d OBLIGATORY DEFAULT '1000'.
SELECT-OPTIONS: s_matkl FOR mara-matkl.
SELECTION-SCREEN END OF BLOCK b1.
TYPES: BEGIN OF ty_ageing_result,
matnr TYPE matnr,
meins TYPE meins,
matkl TYPE matkl,
matkl_bez TYPE wwgbe,
labst TYPE labst,
insme TYPE insme,
speme TYPE speme,
total_stock TYPE meinsm,
last_move_date TYPE datum,
ageing_days TYPE i,
ageing_level TYPE char2,
END OF ty_ageing_result.
DATA: gt_results TYPE TABLE OF ty_ageing_result.
START-OF-SELECTION.
PERFORM get_stock_data CHANGING gt_results.
PERFORM calc_ageing CHANGING gt_results.
PERFORM display_alv USING gt_results.
"=======================================================================
" 子程序 1:获取库存数据
"=======================================================================
FORM get_stock_data CHANGING ct_data TYPE TABLE OF ty_ageing_result.
SELECT mard~matnr, mard~labst, mard~insme, mard~speme,
mara~matkl, mara~meins, twt~wwgbe
INTO TABLE @ct_data
FROM mard
INNER JOIN mara ON mara~matnr = mard~matnr
LEFT JOIN twt ON twt~matkl = mara~matkl
WHERE mard~werks = @p_werks
AND mara~matkl IN @s_matkl.
LOOP AT ct_data INTO DATA(ls_data).
ls_data-total_stock = ls_data-labst + ls_data-insme - ls_data-speme.
MODIFY ct_data FROM ls_data.
ENDLOOP.
IF ct_data IS INITIAL.
RETURN.
ENDIF.
" 收集物料号
DATA: lt_matnr TYPE TABLE OF matnr.
LOOP AT ct_data INTO DATA(ls_d).
APPEND ls_d-matnr TO lt_matnr.
ENDLOOP.
SORT lt_matnr.
DELETE ADJACENT DUPLICATES FROM lt_matnr.
" 查询每个物料最新移动日期
SELECT mseg~matnr, mkpf~budat AS last_move
INTO TABLE @DATA(lt_last_move)
FROM mseg
INNER JOIN mkpf ON mkpf~mblnr = mseg~mblnr AND mkpf~jahr = mseg~jahr
FOR ALL ENTRIES IN @lt_matnr
WHERE mseg~matnr = @lt_matnr-table_line
AND mseg~werks = @p_werks
ORDER BY mseg~matnr, mkpf~budat DESCENDING.
SORT lt_last_move BY matnr DESCENDING.
DELETE ADJACENT DUPLICATES FROM lt_last_move COMPARING matnr.
LOOP AT ct_data INTO DATA(ls_data2).
READ TABLE lt_last_move INTO DATA(ls_move)
WITH KEY matnr = ls_data2-matnr.
IF sy-subrc = 0.
ls_data2-last_move_date = ls_move-last_move.
ELSE.
SELECT SINGLE erdat INTO @DATA(lv_erdat)
FROM mara WHERE matnr = @ls_data2-matnr.
ls_data2-last_move_date = lv_erdat.
ENDIF.
MODIFY ct_data FROM ls_data2.
ENDLOOP.
ENDFORM.
"=======================================================================
" 子程序 2:计算库龄分级
"=======================================================================
FORM calc_ageing CHANGING ct_data TYPE TABLE OF ty_ageing_result.
DATA(lv_today) = sy-datum.
LOOP AT ct_data INTO DATA(ls_data).
IF ls_data-last_move_date IS INITIAL.
ls_data-ageing_days = 9999.
ls_data-ageing_level = '06'.
MODIFY ct_data FROM ls_data.
CONTINUE.
ENDIF.
ls_data-ageing_days = lv_today - ls_data-last_move_date.
CASE.
WHEN ls_data-ageing_days <= 30. ls_data-ageing_level = '01'.
WHEN ls_data-ageing_days <= 60. ls_data-ageing_level = '02'.
WHEN ls_data-ageing_days <= 90. ls_data-ageing_level = '03'.
WHEN ls_data-ageing_days <= 180. ls_data-ageing_level = '04'.
WHEN ls_data-ageing_days <= 365. ls_data-ageing_level = '05'.
WHEN OTHERS. ls_data-ageing_level = '06'.
ENDCASE.
MODIFY ct_data FROM ls_data.
ENDLOOP.
ENDFORM.
"=======================================================================
" 子程序 3:ALV 显示
"=======================================================================
FORM display_alv USING it_data TYPE TABLE OF ty_ageing_result.
DATA: lo_alv TYPE REF TO cl_salv_table.
IF it_data IS INITIAL.
WRITE: / '没有库存数据'.
STOP.
ENDIF.
TRY.
cl_salv_table=>factory(
IMPORTING r_salv_table = lo_alv
CHANGING t_table = it_data ).
lo_alv->get_columns( )->set_optimize( 'X' ).
" 设置列标题
lo_alv->get_columns( )->get_column( 'AGEING_DAYS' )->set_medium_text( '库龄天数' ).
lo_alv->get_columns( )->get_column( 'AGEING_LEVEL' )->set_medium_text( '库龄分级' ).
lo_alv->display( ).
CATCH cx_salv_msg INTO DATA(lx_msg).
MESSAGE lx_msg TYPE 'E'.
ENDTRY.
ENDFORM.
常见问题与排查
-
Q1:MIGO_GOODS_MOVEMENT_POSTING 和 MB_MIGO_BADI 选哪个?
A:功能定位不同。
MIGO_GOODS_MOVEMENT_POSTING在过账前最后一步 触发,适合做最终校验(本篇选这个);MB_MIGO_BADI在 MIGO 界面加载/用户操作时触发,适合做界面增强、子屏幕、动态字段修改等交互逻辑。两者可以组合使用。 -
Q2:MIGO 校验中设置 CV_DONT_POST = 'X',SAP 会 ROLLBACK 吗?
A:会。设置
CV_DONT_POST = 'X'后,SAP 会:①阻止物料凭证过账;②回滚本次 MIGO 操作中的所有数据修改;③给出 E 类型消息提示。但 MIGO 界面不会关闭,用户可以修正数据后重新提交------这比直接抛出 DUMP 友好得多。 -
Q3:批次自动确定 BADI_GOITEM~DETERMINE_BATCH 什么时候被调用?
A:SAP 标准批次确定(MIGO 界面自动选批次、或用户按"批次确定"按钮)失败时,才会调用你的 BAdI 实现。也就是说:SAP 内部逻辑先试一遍,试不出来再找你。如果你想完全接管批次确定,需要实现 SAP 的增强配置(事务码 OMSJ)。
-
Q4:库龄分析报表中,"最后一次物料移动日期"怎么查才高效?
A:MKPF/MSEG 是大表(几千万行),不能全表扫。推荐做法:①
FOR ALL ENTRIES只查你需要的物料号;②ORDER BY BUDAT DESCENDING+DELETE ADJACENT DUPLICATES去重取最新;③如果还慢,建议建索引(MSEG~MATNR+WERKS+BUDAT)或做 CQE 视图。 -
Q5:批次效期扫描为什么用后台 Job,不在 MIGO 中做?
A:三个原因:① MIGO 操作是前台同步 ,效期检查如果需要复杂计算会拖慢用户操作;② 后台 Job 是异步批量,可以一次处理全工厂所有批次;③ 预警邮件这种通知逻辑不应该在用户操作中做------用户只想知道"这次 MIGO 为什么被挡了",不想收到一堆邮件。
-
Q6:出入库校验中,工厂和存储位置的权限怎么加?
A:MIGO 本身有标准权限检查(
M_MSEG_WWA/M_MSEG_WWP),增强中不需要重复检查。但如果需要更细粒度 的权限(比如某些人只能操作特定存储位置),可以在MIGO_GOODS_MOVEMENT_POSTING中读取用户的权限对象(AS03/AS04),不满足则 E 消息阻止。
总结
库存管理三大增强场景速查
| 场景 | 实现方式 | BAdI / 函数 | 触发时机 |
|---|---|---|---|
| MIGO 出入库校验 | MIGO_GOODS_MOVEMENT_POSTING | CHECK 方法 | 过账前最后校验 |
| 批次效期预警 | 定时 Job | ZMM_BATCH_EXPIRY_ALERT | 每天凌晨 |
| 库龄分析报表 | ALV 报表 | ZMM_STOCK_AGEING | 用户执行 |
关键代码避坑速查
| 坑 | 错误做法 | 正确做法 |
|---|---|---|
| BAdI 接口方法名 | if_ex_migo_goods_movement_po~check |
CHECK(大写) |
| 库存字段来源 | marc~labst(不存在) |
mard~labst(MARD 表) |
| 批次效期字段 | mchb~vfdat(不存在) |
chdo~vfdat(CHDO 表) |
| 库龄批量查询 | 循环中 SELECT | FOR ALL ENTRIES + ORDER BY DESCENDING |
| 更新函数提交 | COMMIT WORK |
不写,让框架提交 |
| CV_DONT_POST 后 | 未中断循环 | 每次都检查并 CONTINUE |
MM 模块增强全景(8 篇中的 4 篇已完成)
#mermaid-svg-XNHvqWwfb7ktlfiq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XNHvqWwfb7ktlfiq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XNHvqWwfb7ktlfiq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XNHvqWwfb7ktlfiq .error-icon{fill:#552222;}#mermaid-svg-XNHvqWwfb7ktlfiq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XNHvqWwfb7ktlfiq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XNHvqWwfb7ktlfiq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XNHvqWwfb7ktlfiq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XNHvqWwfb7ktlfiq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XNHvqWwfb7ktlfiq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XNHvqWwfb7ktlfiq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XNHvqWwfb7ktlfiq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XNHvqWwfb7ktlfiq .marker.cross{stroke:#333333;}#mermaid-svg-XNHvqWwfb7ktlfiq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XNHvqWwfb7ktlfiq p{margin:0;}#mermaid-svg-XNHvqWwfb7ktlfiq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XNHvqWwfb7ktlfiq .cluster-label text{fill:#333;}#mermaid-svg-XNHvqWwfb7ktlfiq .cluster-label span{color:#333;}#mermaid-svg-XNHvqWwfb7ktlfiq .cluster-label span p{background-color:transparent;}#mermaid-svg-XNHvqWwfb7ktlfiq .label text,#mermaid-svg-XNHvqWwfb7ktlfiq span{fill:#333;color:#333;}#mermaid-svg-XNHvqWwfb7ktlfiq .node rect,#mermaid-svg-XNHvqWwfb7ktlfiq .node circle,#mermaid-svg-XNHvqWwfb7ktlfiq .node ellipse,#mermaid-svg-XNHvqWwfb7ktlfiq .node polygon,#mermaid-svg-XNHvqWwfb7ktlfiq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XNHvqWwfb7ktlfiq .rough-node .label text,#mermaid-svg-XNHvqWwfb7ktlfiq .node .label text,#mermaid-svg-XNHvqWwfb7ktlfiq .image-shape .label,#mermaid-svg-XNHvqWwfb7ktlfiq .icon-shape .label{text-anchor:middle;}#mermaid-svg-XNHvqWwfb7ktlfiq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XNHvqWwfb7ktlfiq .rough-node .label,#mermaid-svg-XNHvqWwfb7ktlfiq .node .label,#mermaid-svg-XNHvqWwfb7ktlfiq .image-shape .label,#mermaid-svg-XNHvqWwfb7ktlfiq .icon-shape .label{text-align:center;}#mermaid-svg-XNHvqWwfb7ktlfiq .node.clickable{cursor:pointer;}#mermaid-svg-XNHvqWwfb7ktlfiq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XNHvqWwfb7ktlfiq .arrowheadPath{fill:#333333;}#mermaid-svg-XNHvqWwfb7ktlfiq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XNHvqWwfb7ktlfiq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XNHvqWwfb7ktlfiq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XNHvqWwfb7ktlfiq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XNHvqWwfb7ktlfiq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XNHvqWwfb7ktlfiq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XNHvqWwfb7ktlfiq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XNHvqWwfb7ktlfiq .cluster text{fill:#333;}#mermaid-svg-XNHvqWwfb7ktlfiq .cluster span{color:#333;}#mermaid-svg-XNHvqWwfb7ktlfiq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XNHvqWwfb7ktlfiq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XNHvqWwfb7ktlfiq rect.text{fill:none;stroke-width:0;}#mermaid-svg-XNHvqWwfb7ktlfiq .icon-shape,#mermaid-svg-XNHvqWwfb7ktlfiq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XNHvqWwfb7ktlfiq .icon-shape p,#mermaid-svg-XNHvqWwfb7ktlfiq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XNHvqWwfb7ktlfiq .icon-shape .label rect,#mermaid-svg-XNHvqWwfb7ktlfiq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XNHvqWwfb7ktlfiq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XNHvqWwfb7ktlfiq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XNHvqWwfb7ktlfiq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} MM 模块增强开发体系
已完成(4 篇)
待完成(4 篇)
采购订单增强
ME_PROCESS_PO_CUST
物料主数据增强
BADI_MATERIAL_CHECK + 批量导入
采购申请增强
ME_PROCESS_REQ_CUST + 自动转单
库存管理增强
MIGO + 批次 + 库龄(本篇)
第五篇:发票校验增强
MIRO 三单匹配 + 自动过账
第六篇:供应商主数据增强
字段扩展 + 分级管控 + 同步
第七篇:MM 报表开发
采购执行 + 库存分析 + 供应商绩效
第八篇:MM 接口开发
SRM / WMS / MDG 多系统对接
本篇与下一篇的衔接
| 本篇(MM 库存管理) | 下一篇(MM 发票校验) |
|---|---|
| MIGO 入库/出库校验 | MIRO 发票三单匹配 |
| MIGO_GOODS_MOVEMENT_POSTING | EXIT_SAPLMRMH_014(用户出口) |
| PO 匹配 + 库存检查 | PO + GR + Invoice 三单匹配 |
| 批次效期 FEFO | 发票金额容差 + 税额校验 |
下一篇预告:《MM 模块发票校验开发:三单匹配、增强校验与财务过账逻辑实现》------采购流程的最后一环:发票校验(MIRO)。下一篇讲解 MIRO 发票校验增强开发、采购订单/入库单/发票三单匹配逻辑开发、发票自动过账到 FI 模块的接口开发、发票校验差异处理逻辑开发的方法,附"发票金额超订单容差自动拦截""税额自动校验"的实现代码。
作者 :爱喝水的鱼丶
版本记录:2026 年 9 月
💬 你在库存管理上做过哪些增强?MIGO 增强最容易踩的坑是什么?FEFO 批次自动确定和人工选批次相比,你的项目用哪种方式?欢迎分享你的库存管理增强经验。