feat(return): 逆向物流数据模型与迁移

新增原单退回与不良品在管的持久化结构。

- TransOutbound 增 returned_quantity(numeric(19,4),非 float):该值参与
  「return_qty <= quantity - returned_quantity」判等,浮点误差会让反复部分
  退回后出现「已退满却判定未退满」的错判
- 新增 TransReturn:退回流水,每次退回写一条而非覆盖式更新。刻意与
  trans_borrow 划清界限——后者部分归还时会覆盖 return_time/operator,
  导致归还历史永久丢失
- 新增 TransDefectiveGoods:不良品在管台账。坏件全程不入库存表,因为
  status 是行级属性而质量是件级属性,把坏件加回原行只能整行打不良
  (实测 stock_buy 单行最大 4789 件、中位 8 件,整行打不良会凭空损失良品)
- 状态机:待处理 → 处理中 → {已回库|已报废|已闭环}。终态由累计去向推导
  而非「最后一次动作」——一批坏件可能既回库过又报废过,按最后动作定状态
  会产生误导
- restocked_qty/scrapped_qty 两列:二期用 quantity-remaining_qty 反推回库量,
  三期加入报废出口后该反推失效
- 审计白名单与模型预加载同步登记(监听器绑定 18 → 20 个模型)

迁移脚本均为纯追加式 DDL,含预检、回滚段与执行后核对。首个脚本用
COALESCE 包裹数量列——库存表允许数量为 NULL,而「NULL 大于 0」求值为
NULL 而非真,裸写会让脏行在预览与诊断两次查询里凭空消失。
This commit is contained in:
yueli
2026-09-16 15:45:11 +08:00
parent fbc9296056
commit 69c38a1bf7
6 changed files with 447 additions and 2 deletions

View File

@ -0,0 +1,141 @@
-- =============================================================================
-- 二期迁移:通用原单退回 + 不良品在管台账
--
-- 背景
-- 逆向物流二期。出库后的实物退回分两条路径:
-- · 良品退回(错领/多领)—— 直接加回原库存行
-- · 不良品退回 —— **不写库存表**,转入独立的坏件在管台账
-- trans_defective_goods修好后一键回库
--
-- 不良品不入库存表是本次的**核心架构决策**:库存表的 status 是行级属性,
-- 而质量是件级属性。若把坏件加回原行,一行可能同时含良品与坏件,只能整行
-- 打不良(实测 stock_buy 单行最大 4789 件,中位 8 件,整行打不良会凭空
-- 损失大量良品)。故坏件全程独立于库存表,只在修好回库时才回到原行。
--
-- 与之配套worklist 表 trans_defective_goods 与维修模块trans_repair
-- **完全解耦** —— trans_repair 是 SN 单台粒度、无任何数量列,承载不了
-- "一批坏件"(实测 50.6% 的出库是多件,中位 2、最大 186
--
-- 变更内容(全部为**追加式**,不改动/不删除任何既有列与行)
-- 1) trans_outbound 新增 returned_quantity —— 每条出库明细的累计退回量
-- 2) 新建 trans_return —— 退回流水(每一次退回一条)
-- 3) 新建 trans_defective_goods —— 不良品在管台账(可部分回库)
--
-- ★ 类型选择说明returned_quantity 用 numeric(19,4) 而非 float。
-- 本系统所有数量列一律 numeric(19,4)trans_outbound.quantity /
-- trans_borrow.returned_quantity 等),且 returned_quantity 要参与
-- `return_qty <= quantity - returned_quantity` 这种判等/比较运算。
-- 用 float 会引入二进制浮点误差,反复部分退回后可能出现
-- "已退满却仍判定为未退满"或反之的错判。
--
-- 安全性
-- · 纯追加式 DDLADD COLUMN 带 DEFAULT 0既有 1077 行出库记录自动补 0
-- 不改写任何业务数据;两张新表不影响现有查询。
-- · 幂等:全部使用 IF NOT EXISTS可重复执行。
-- · 回滚:见文末「回滚段」(同样为纯 DDL不触碰业务数据
--
-- 执行
-- docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
-- ---------------------------------------------------------------------------
-- 1) 出库明细:累计退回量
-- ---------------------------------------------------------------------------
ALTER TABLE trans_outbound
ADD COLUMN IF NOT EXISTS returned_quantity numeric(19,4) NOT NULL DEFAULT 0;
COMMENT ON COLUMN trans_outbound.returned_quantity IS '累计已退回数量(良品+不良品),不得超过 quantity';
-- ---------------------------------------------------------------------------
-- 2) 退回流水
-- 每一次退回写一条,**不做覆盖式更新** —— 避免重演 trans_borrow 归还时
-- 把 return_time/operator 覆盖掉、导致部分归还历史丢失的老问题。
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS trans_return (
id serial PRIMARY KEY,
outbound_id integer NOT NULL, -- 原出库明细 trans_outbound.id
stock_id integer, -- 原库存行 id快照
source_table varchar(50), -- 原库存表名(快照)
sku varchar(100), -- 冗余,便于列表展示免联表
return_qty numeric(19,4) NOT NULL DEFAULT 0,
return_type varchar(20) NOT NULL, -- '良品' | '不良品'
reason text,
operator varchar(100),
return_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE trans_return IS '原单退回流水(每次退回一条,不覆盖)';
COMMENT ON COLUMN trans_return.return_type IS '良品 / 不良品';
CREATE INDEX IF NOT EXISTS ix_trans_return_outbound
ON trans_return (outbound_id);
CREATE INDEX IF NOT EXISTS ix_trans_return_stock
ON trans_return (source_table, stock_id);
CREATE INDEX IF NOT EXISTS ix_trans_return_time
ON trans_return (return_time);
-- ---------------------------------------------------------------------------
-- 3) 不良品在管台账
-- 一行 = 一批同源坏件。支持部分回库remaining_qty 随回库递减。
-- 状态机:待处理 → (部分回库) → 已回库
-- ↘ 已报废
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS trans_defective_goods (
id serial PRIMARY KEY,
return_id integer, -- 来源 trans_return.id追溯
outbound_id integer, -- 来源 trans_outbound.id追溯
source_table varchar(50) NOT NULL, -- 回库目标库存表
stock_id integer NOT NULL, -- 回库目标库存行
base_id integer, -- 物料主数据(联表展示用)
sku varchar(100),
material_name varchar(200),
spec_model varchar(255),
quantity numeric(19,4) NOT NULL DEFAULT 0, -- 进入在管时的原始数量
remaining_qty numeric(19,4) NOT NULL DEFAULT 0, -- 当前仍在管数量
status varchar(20) NOT NULL DEFAULT '待处理',
company_name varchar(255), -- 行级隔离用(本表承载实物,按库存表口径存公司)
reason text,
operator varchar(100),
remark text,
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE trans_defective_goods IS '不良品在管台账;与库存表解耦,回库时才回到 source_table#stock_id';
COMMENT ON COLUMN trans_defective_goods.remaining_qty IS '仍在管数量;每次回库递减,归零即整批回库完成';
CREATE INDEX IF NOT EXISTS ix_tdg_status
ON trans_defective_goods (status);
CREATE INDEX IF NOT EXISTS ix_tdg_source
ON trans_defective_goods (source_table, stock_id);
CREATE INDEX IF NOT EXISTS ix_tdg_company
ON trans_defective_goods (company_name);
CREATE INDEX IF NOT EXISTS ix_tdg_outbound
ON trans_defective_goods (outbound_id);
-- ---------------------------------------------------------------------------
-- 4) 执行后核对
-- ---------------------------------------------------------------------------
\echo '--- 1) returned_quantity 列已就位,既有行补 0 ---'
SELECT count(*) AS outbound_rows,
count(*) FILTER (WHERE returned_quantity = 0) AS zero_filled
FROM trans_outbound;
\echo '--- 2) 新表已建立 ---'
SELECT tablename FROM pg_tables
WHERE tablename IN ('trans_return', 'trans_defective_goods') ORDER BY 1;
COMMIT;
-- =============================================================================
-- 回滚段(仅在需要撤销本次迁移时执行;同样纯 DDL不动业务数据
-- 注意:一旦已有退回/在管数据落库,回滚会一并丢弃这些数据。
-- =============================================================================
-- BEGIN;
-- DROP TABLE IF EXISTS trans_defective_goods;
-- DROP TABLE IF EXISTS trans_return;
-- ALTER TABLE trans_outbound DROP COLUMN IF EXISTS returned_quantity;
-- COMMIT;

View File

@ -0,0 +1,84 @@
-- =============================================================================
-- 三期迁移:坏件处置量跟踪(配合三期的「坏件报废」闭环)
--
-- 背景
-- 二期只支持坏件的「回库」一种出口,故用 `quantity - remaining_qty` 就能
-- 反推已回库量。三期补上「报废」出口后,这个反推就不再成立 ——
-- 它会把报废掉的数量误算成已回库量。
--
-- 因此拆出两个独立累计列restocked_qty累计回库与 scrapped_qty累计报废
--
-- ★ 不变式(全表恒成立,测试中逐行校验):
-- restocked_qty + scrapped_qty + remaining_qty = quantity
-- 暂不加 DB 层 CHECK 约束:本系统库存数量普遍以 Python float 累加后再
-- 落 numeric(19,4),极端小数下可能出现 4 位以外的舍入抖动,硬约束会把
-- 一次合法操作变成 500。改为在接口层维护并由测试守住不变式。
--
-- 状态值归一
-- 二期把「部分处置」命名为 '部分回库'。三期报废也会产生部分状态,旧名不再
-- 准确,统一改为 '处理中'。新状态机(取值见 app/models/transaction.py
-- 待处理 → 处理中 → ┬ 已回库(全部回库)
-- ├ 已报废(全部报废)
-- └ 已闭环(回库与报废混合)
--
-- 安全性:纯追加式 DDL新列带 DEFAULT 0既有行自动补 0。
-- 幂等:全部 IF NOT EXISTS / 带条件的 UPDATE可重复执行。
--
-- 执行
-- docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
-- ---------------------------------------------------------------------------
-- 1) 处置量跟踪列
-- ---------------------------------------------------------------------------
ALTER TABLE trans_defective_goods
ADD COLUMN IF NOT EXISTS restocked_qty numeric(19,4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS scrapped_qty numeric(19,4) NOT NULL DEFAULT 0;
COMMENT ON COLUMN trans_defective_goods.restocked_qty IS '累计已回库数量';
COMMENT ON COLUMN trans_defective_goods.scrapped_qty IS '累计已报废数量';
-- ---------------------------------------------------------------------------
-- 2) 存量回填
-- 迁移前只有「回库」一种出口,故「已减少的在管量」全部归入 restocked_qty。
-- 本表在写此迁移时为空,此段为幂等防御,防止在其它环境(已有数据)执行时
-- 出现 restocked/scrapped 全 0 而 remaining 已减少的不一致状态。
-- ---------------------------------------------------------------------------
UPDATE trans_defective_goods
SET restocked_qty = GREATEST(COALESCE(quantity, 0) - COALESCE(remaining_qty, 0), 0)
WHERE restocked_qty = 0
AND scrapped_qty = 0
AND COALESCE(quantity, 0) > COALESCE(remaining_qty, 0);
-- ---------------------------------------------------------------------------
-- 3) 状态值归一:'部分回库' → '处理中'
-- ---------------------------------------------------------------------------
UPDATE trans_defective_goods SET status = '处理中' WHERE status = '部分回库';
-- ---------------------------------------------------------------------------
-- 4) 执行后核对:不一致行数必须为 0
-- ---------------------------------------------------------------------------
\echo '--- 处置量不变式核对inconsistent 应为 0---'
SELECT count(*) AS total,
count(*) FILTER (
WHERE COALESCE(restocked_qty,0) + COALESCE(scrapped_qty,0)
+ COALESCE(remaining_qty,0) <> COALESCE(quantity,0)
) AS inconsistent
FROM trans_defective_goods;
\echo '--- 状态取值分布 ---'
SELECT COALESCE(status, '<NULL>') AS status, count(*) FROM trans_defective_goods GROUP BY 1;
COMMIT;
-- =============================================================================
-- 回滚段(仅撤销三期变更;不动二期表结构)
-- =============================================================================
-- BEGIN;
-- ALTER TABLE trans_defective_goods DROP COLUMN IF EXISTS restocked_qty;
-- ALTER TABLE trans_defective_goods DROP COLUMN IF EXISTS scrapped_qty;
-- UPDATE trans_defective_goods SET status = '部分回库' WHERE status = '处理中';
-- COMMIT;

View File

@ -310,7 +310,14 @@ def create_app():
# 系统与业务模型 (SysRolePermission 等在 models.system 中)
from app.models.system import SysUser, SysLog, SysMenu, SysElement, SysRolePermission, SysWarehouseLocation
# 确保借还模型被加载
from app.models.transaction import TransBorrow, TransRepair, TransScrap
# ★ TransReturn / TransDefectiveGoods 为二期逆向物流新增,必须在此
# 预加载:审计监听器按表名从 db.metadata 取模型,未预加载的表在
# create_app() 完成时尚未映射,会漏绑审计(虽然后续惰性补绑能兜底,
# 但预加载更可靠)。
from app.models.transaction import (
TransBorrow, TransRepair, TransScrap,
TransReturn, TransDefectiveGoods,
)
# ★ 审批单模型(原仅在函数体内延迟导入,会导致审计监听器漏绑)
from app.models.outbound import OutboundApproval
from app.models.borrow import BorrowApproval

View File

@ -41,6 +41,8 @@ WHITELIST_TABLES = {
'trans_borrow',
'trans_scrap',
'trans_repair',
'trans_return', # 原单退回流水(二期)
'trans_defective_goods', # 不良品在管台账(二期)
# --- 库存(三表 + 库存调整)---
'stock_buy',
'stock_semi',
@ -66,6 +68,8 @@ TABLE_LABELS = {
'trans_borrow': '借还流水',
'trans_scrap': '报废流水',
'trans_repair': '维修单',
'trans_return': '退回流水',
'trans_defective_goods': '不良品在管',
'stock_buy': '采购库存',
'stock_semi': '半成品库存',
'stock_product': '成品库存',
@ -185,6 +189,8 @@ def _get_module_name(mapper):
return '报废管理'
if tablename == 'trans_repair':
return '维修管理'
if tablename in ('trans_return', 'trans_defective_goods'):
return '退回管理'
if tablename in ('stock_buy', 'stock_semi', 'stock_product'):
return '库存管理'
if tablename == 'stock_adjustment':

View File

@ -139,16 +139,28 @@ class TransOutbound(db.Model):
# [新增] 出库时的库位快照(从源库存记录带出,便于历史追溯)
warehouse_location = db.Column(db.String(100))
# [新增] 累计已退回数量(良品 + 不良品口径合并),用于原单退回的额度校验。
# ★ 用 numeric(19,4) 而非 float本系统所有数量列一律 numeric(19,4)
# 且该值要参与 `return_qty <= quantity - returned_quantity` 的判等比较,
# 浮点误差会让反复部分退回后出现「已退满却判定未退满」的错判。
# DDL 见 db_migrations/phase2_return_and_defective_goods.sql
returned_quantity = db.Column(db.Numeric(19, 4), nullable=False, default=0)
remark = db.Column(db.Text)
def to_dict(self):
qty = float(self.quantity) if self.quantity else 0
returned = float(self.returned_quantity) if self.returned_quantity is not None else 0
return {
'id': self.id,
'outbound_no': self.outbound_no,
'sku': self.sku,
'source_table': self.source_table,
'outbound_type': self.outbound_type,
'quantity': float(self.quantity) if self.quantity else 0,
'quantity': qty,
# [新增] 退回额度三件套,供前端判断该明细还能退多少
'returned_quantity': returned,
'returnable_quantity': qty - returned,
'unit_price': float(self.unit_price) if self.unit_price else 0,
'consumer_name': self.consumer_name,
'signature_path': self.signature_path,

View File

@ -217,3 +217,198 @@ class TransScrap(db.Model):
'cost_at_scrap': float(self.cost_at_scrap) if self.cost_at_scrap is not None else None,
'total_loss': float(self.total_loss) if self.total_loss is not None else None,
}
# =============================================================================
# 原单退回(逆向物流)
# =============================================================================
# 设计要点
# --------
# 1. **流水不覆盖**:每一次退回写一条 trans_return而不是在原记录上累加覆盖。
# 这是刻意与 trans_borrow 划清界限 —— 后者在部分归还时会把
# return_time / return_operator / return_signature 逐次覆盖,导致
# 「谁在什么时候还了多少」永久丢失。退回流水不能再犯同样的错。
#
# 2. **不良品不入库存表**:库存表的 status 是**行级**属性,而质量是**件级**
# 属性。把坏件加回原行,会让一行同时含良品与坏件 —— 只能整行打不良,
# 而实测 stock_buy 单行最大 4789 件(中位 8 件),整行打不良等于凭空
# 损失大量良品。故坏件全程存放在独立的 trans_defective_goods 台账里,
# 只有修好回库那一刻才回到原库存行。
#
# 3. **与维修模块解耦**trans_repair 是 SN 单台粒度、且没有任何数量列,
# 承载不了「一批坏件」(实测 50.6% 的出库是多件,中位 2、最大 186
# 故坏件台账独立建表,不复用 trans_repair。
RETURN_TYPE_GOOD = '良品'
RETURN_TYPE_DEFECTIVE = '不良品'
VALID_RETURN_TYPES = (RETURN_TYPE_GOOD, RETURN_TYPE_DEFECTIVE)
class TransReturn(db.Model):
"""
原单退回流水。
一行 = 一次退回动作(不做覆盖式更新)。累计退回量另存于
trans_outbound.returned_quantity本表负责回答「每一次是谁、何时、
退了多少、良品还是不良品」。
"""
__tablename__ = 'trans_return'
id = db.Column(db.Integer, primary_key=True)
outbound_id = db.Column(db.Integer, nullable=False, index=True) # 原出库明细 trans_outbound.id
stock_id = db.Column(db.Integer) # 原库存行 id快照
source_table = db.Column(db.String(50)) # 原库存表名(快照)
sku = db.Column(db.String(100)) # 冗余,避免列表页联表
return_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0)
return_type = db.Column(db.String(20), nullable=False) # '良品' | '不良品'
reason = db.Column(db.Text)
operator = db.Column(db.String(100))
return_time = db.Column(db.DateTime, default=beijing_time)
def to_dict(self):
return {
'id': self.id,
'outbound_id': self.outbound_id,
'stock_id': self.stock_id,
'source_table': self.source_table,
'sku': self.sku,
'return_qty': float(self.return_qty) if self.return_qty is not None else 0,
'return_type': self.return_type,
'reason': self.reason,
'operator': self.operator,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
}
# 状态机(三期升级):
# 待处理 ──┬─→ 处理中 ──┬─→ 已回库(全部回库,无报废)
# │ ├─→ 已报废(全部报废,无回库)
# │ └─→ 已闭环(回库与报废混合,在管量归零)
# └─(一次处置即为终态时,直接跳到对应的终态)
#
# ★ 三种终态而非单一「已闭环」,是为了让工作台一眼看出这批坏件的**去向**
# 修好回到库存了、还是被销毁了。混合处置无法用单一去向描述,才归到「已闭环」。
DEFECTIVE_STATUS_PENDING = '待处理' # 刚退回,尚未做任何处置
DEFECTIVE_STATUS_IN_PROGRESS = '处理中' # 已部分回库/部分报废,仍有在管量
DEFECTIVE_STATUS_RESTOCKED = '已回库' # 全部回库,无报废
DEFECTIVE_STATUS_SCRAPPED = '已报废' # 全部报废,无回库
DEFECTIVE_STATUS_CLOSED = '已闭环' # 回库与报废混合,在管量归零
VALID_DEFECTIVE_STATUSES = (
DEFECTIVE_STATUS_PENDING,
DEFECTIVE_STATUS_IN_PROGRESS,
DEFECTIVE_STATUS_RESTOCKED,
DEFECTIVE_STATUS_SCRAPPED,
DEFECTIVE_STATUS_CLOSED,
)
# ★ 可继续处置的状态白名单Fail-Closed未列出的一律拒绝回库/报废)。
# 三种终态都不可再动:已回库→再回库就是凭空多一份库存;已报废→实物已销毁;
# 已闭环→在管量已归零。
OPEN_DEFECTIVE_STATUSES = (
DEFECTIVE_STATUS_PENDING,
DEFECTIVE_STATUS_IN_PROGRESS,
)
# 语义别名:回库与报废的准入白名单是同一组「未结案」状态
RESTOCKABLE_DEFECTIVE_STATUSES = OPEN_DEFECTIVE_STATUSES
SCRAPPABLE_DEFECTIVE_STATUSES = OPEN_DEFECTIVE_STATUSES
def defective_close_status(restocked_qty, scrapped_qty):
"""
在管量归零时,由累计去向推导终态。
★ 为什么需要它:一次坏件批次可能既回库了一部分、又报废了剩余部分。
此时 remaining_qty 归零,但既不是「已回库」也不是「已报废」——
按最后一次动作定状态会产生误导(最后报废 ≠ 整批报废)。
故由累计量推导,语义稳定且与动作顺序无关。
"""
restocked = float(restocked_qty or 0)
scrapped = float(scrapped_qty or 0)
if restocked > 0 and scrapped > 0:
return DEFECTIVE_STATUS_CLOSED
if scrapped > 0:
return DEFECTIVE_STATUS_SCRAPPED
return DEFECTIVE_STATUS_RESTOCKED
class TransDefectiveGoods(db.Model):
"""
不良品在管台账。
一行 = 一批同源坏件。支持**部分回库**remaining_qty 随每次回库递减,
归零才算整批回库完成。
本表与库存表解耦:坏件在管期间不占用任何库存行的数量,也不改其 status。
回库时才按 source_table + stock_id 回到原库存行。
"""
__tablename__ = 'trans_defective_goods'
id = db.Column(db.Integer, primary_key=True)
# --- 来源追溯 ---
return_id = db.Column(db.Integer, index=True) # trans_return.id
outbound_id = db.Column(db.Integer, index=True) # trans_outbound.id
# --- 回库目标(原库存行)---
source_table = db.Column(db.String(50), nullable=False)
stock_id = db.Column(db.Integer, nullable=False)
# --- 物料快照(回库目标行可能被删,此处保留可读信息)---
base_id = db.Column(db.Integer)
sku = db.Column(db.String(100))
material_name = db.Column(db.String(200))
spec_model = db.Column(db.String(255))
# --- 数量 ---
# ★ 不变式restocked_qty + scrapped_qty + remaining_qty = quantity
# 三个去向列相互独立,不可互推 —— 二期曾用 quantity - remaining_qty
# 反推回库量,三期加入报废出口后该反推即失效。
quantity = db.Column(db.Numeric(19, 4), nullable=False, default=0) # 进入在管时的原始数量
remaining_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) # 仍在管数量
restocked_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) # [三期] 累计已回库
scrapped_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) # [三期] 累计已报废
# --- 状态与归属 ---
status = db.Column(db.String(20), nullable=False, default=DEFECTIVE_STATUS_PENDING)
company_name = db.Column(db.String(255), index=True) # 行级隔离:本表承载实物,按库存表口径存公司
reason = db.Column(db.Text)
operator = db.Column(db.String(100))
remark = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=beijing_time)
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time)
def to_dict(self):
qty = float(self.quantity) if self.quantity is not None else 0
remain = float(self.remaining_qty) if self.remaining_qty is not None else 0
restocked = float(self.restocked_qty) if self.restocked_qty is not None else 0
scrapped = float(self.scrapped_qty) if self.scrapped_qty is not None else 0
return {
'id': self.id,
'return_id': self.return_id,
'outbound_id': self.outbound_id,
'source_table': self.source_table,
'stock_id': self.stock_id,
'base_id': self.base_id,
'sku': self.sku,
'material_name': self.material_name,
'spec_model': self.spec_model,
'quantity': qty,
'remaining_qty': remain,
# ★ 三个去向列各自独立取值。改造前 restocked_qty 由
# quantity - remaining_qty 反推,三期加入报废出口后会算错。
'restocked_qty': restocked,
'scrapped_qty': scrapped,
'status': self.status,
'company_name': self.company_name,
'reason': self.reason,
'operator': self.operator,
'remark': self.remark,
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
}