From 69c38a1bf7a509ea28ebfaec23c9aa0ee1bd25f1 Mon Sep 17 00:00:00 2001 From: yueli Date: Wed, 16 Sep 2026 15:45:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(return):=20=E9=80=86=E5=90=91=E7=89=A9?= =?UTF-8?q?=E6=B5=81=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B=E4=B8=8E=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增原单退回与不良品在管的持久化结构。 - 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 而非真,裸写会让脏行在预览与诊断两次查询里凭空消失。 --- .../phase2_return_and_defective_goods.sql | 141 +++++++++++++ .../phase3_defective_qty_tracking.sql | 84 ++++++++ inventory-backend/app/__init__.py | 9 +- inventory-backend/app/core/audit_listener.py | 6 + inventory-backend/app/models/outbound.py | 14 +- inventory-backend/app/models/transaction.py | 195 ++++++++++++++++++ 6 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 db_migrations/phase2_return_and_defective_goods.sql create mode 100644 db_migrations/phase3_defective_qty_tracking.sql diff --git a/db_migrations/phase2_return_and_defective_goods.sql b/db_migrations/phase2_return_and_defective_goods.sql new file mode 100644 index 0000000..4ee2e8a --- /dev/null +++ b/db_migrations/phase2_return_and_defective_goods.sql @@ -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 会引入二进制浮点误差,反复部分退回后可能出现 +-- "已退满却仍判定为未退满"或反之的错判。 +-- +-- 安全性 +-- · 纯追加式 DDL:ADD 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; diff --git a/db_migrations/phase3_defective_qty_tracking.sql b/db_migrations/phase3_defective_qty_tracking.sql new file mode 100644 index 0000000..73c1265 --- /dev/null +++ b/db_migrations/phase3_defective_qty_tracking.sql @@ -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, '') 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; diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index e3609ca..fa42c56 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -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 diff --git a/inventory-backend/app/core/audit_listener.py b/inventory-backend/app/core/audit_listener.py index f475d78..498e904 100644 --- a/inventory-backend/app/core/audit_listener.py +++ b/inventory-backend/app/core/audit_listener.py @@ -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': diff --git a/inventory-backend/app/models/outbound.py b/inventory-backend/app/models/outbound.py index 94b9d2d..84b2739 100644 --- a/inventory-backend/app/models/outbound.py +++ b/inventory-backend/app/models/outbound.py @@ -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, diff --git a/inventory-backend/app/models/transaction.py b/inventory-backend/app/models/transaction.py index 0adab1f..d853c58 100644 --- a/inventory-backend/app/models/transaction.py +++ b/inventory-backend/app/models/transaction.py @@ -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, + }