from app.extensions import db, beijing_time from datetime import datetime from sqlalchemy import func from functools import lru_cache @lru_cache(maxsize=512) def _borrow_user_name(uid): """按用户 ID 取用户名(带缓存,避免列表页 N+1)""" try: from app.models.system import SysUser u = SysUser.query.get(uid) return u.username if u else None except Exception: return None def _display_borrow_operator(op): """归还人展示映射:历史存的是数字 user id → 映射为用户名;新数据存姓名则原样返回""" if op and str(op).strip().isdigit(): mapped = _borrow_user_name(int(op)) return mapped if mapped else op return op class TransBorrow(db.Model): __tablename__ = 'trans_borrow' id = db.Column(db.Integer, primary_key=True) borrow_no = db.Column(db.String(100)) sku = db.Column(db.String(100)) source_table = db.Column(db.String(50)) stock_id = db.Column(db.Integer) barcode = db.Column(db.String(100)) quantity = db.Column(db.Numeric(19, 4)) returned_quantity = db.Column(db.Numeric(19, 4), default=0) borrower_name = db.Column(db.String(100)) borrow_time = db.Column(db.DateTime, default=beijing_time) borrow_signature = db.Column(db.Text) # [一期收口] 执行借出(扫码发货)的**库管操作人**姓名。 # 此前 execute_dispatch 的 operator_name 形参被接收后从未落库,责任链上 # 「谁经手发货的」一直缺失。仅展示/追溯用,不参与权限判定或持有人校验。 # ★ 该列上线前的历史记录为 NULL(执行人信息从未被采集,无从回填)。 dispatch_operator = db.Column(db.String(100)) expected_return_time = db.Column(db.DateTime) is_returned = db.Column(db.Boolean, default=False) return_time = db.Column(db.DateTime) return_operator = db.Column(db.String(100)) return_signature = db.Column(db.Text) return_location = db.Column(db.String(100)) # [新增] 借出时的库位快照(从源库存记录带出,便于历史追溯) location = db.Column(db.String(100)) status = db.Column(db.String(20), default='borrowed') remark = db.Column(db.Text) # ========================================================================= # 借库转交(一期)—— 身份 ID 锚点 # # ★ 为什么 ID 与姓名快照并存: # ID 是**唯一**的身份锚点( borrower_name 是字符串,实测 85 行里 18 个姓名 # 映射到 18 个人,一旦重名责任链即断);姓名字段保留为**展示快照**, # 避免列表页逐行回查 sys_user 造成 N+1,且用户行被删除后台账仍可读 # (与 trans_return.company_name 同一处理方式)。 # # ★ 为什么 _id 允许 NULL: # 仅迁移前的历史行可能为 NULL —— 迁移脚本按「姓名唯一命中 sys_user」回填, # 85 行中 84 行成功,1 行(「测试01」)姓名无法映射故留空。 # 新数据由 execute_dispatch 强制写入 borrower_id,不存在 NULL。 # # ★ borrower_id 与 current_holder_id 的区别: # borrower_id = 初始借用人,写一次不再变(回答「这单最初谁借的」); # current_holder_id = 当前实际持有人,转交会推进它(回答「东西现在在谁手上」)。 # 归还走完后 current_holder_id 被清空,borrower_id 保留作历史。 # ========================================================================= borrower_id = db.Column(db.Integer, index=True) # 初始借用人ID current_holder_id = db.Column(db.Integer, index=True) # 当前持有人ID current_holder_name = db.Column(db.String(100)) # 当前持有人姓名快照 def to_dict(self): returned_qty = float(self.returned_quantity) if self.returned_quantity is not None else 0 total_qty = float(self.quantity) if self.quantity is not None else 0 pending_qty = total_qty - returned_qty return { 'id': self.id, 'borrow_no': self.borrow_no, 'sku': self.sku, 'source_table': self.source_table, 'stock_id': self.stock_id, 'barcode': self.barcode, 'quantity': total_qty, 'returned_quantity': returned_qty, 'pending_quantity': pending_qty, 'borrower_id': self.borrower_id, 'borrower_name': self.borrower_name, # ★ 当前持有人:转交后前端据此展示「东西在谁手上」并预填归还人 'current_holder_id': self.current_holder_id, 'current_holder_name': self.current_holder_name, 'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None, # 执行借出的库管操作人(一期收口;历史行为 NULL) 'dispatch_operator': self.dispatch_operator, 'borrow_signature': self.borrow_signature, 'expected_return_time': self.expected_return_time.strftime('%Y-%m-%d %H:%M') if self.expected_return_time else None, 'is_returned': self.is_returned, 'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else None, 'return_operator': _display_borrow_operator(self.return_operator), 'return_signature': self.return_signature, 'return_location': self.return_location, 'location': self.location or '', 'status': self.status, 'remark': self.remark, } @classmethod def get_borrowed_quantity(cls, source_table, stock_id): """ 获取指定库存记录(source_table 和 stock_id)的借出未还数量总和。 返回浮点数,若无借出记录则返回 0.0。 """ result = db.session.query(func.sum(cls.quantity)).filter( cls.source_table == source_table, cls.stock_id == stock_id, cls.is_returned == False ).scalar() return float(result) if result is not None else 0.0 # ============================================================================= # 借库转交流水(一期) # ============================================================================= # 设计要点 # -------- # 1. **流水不覆盖**:一行 = 一次转交动作。trans_borrow 是单行模型,只能存 # 「当前持有人」一个值;A→B→C 的完整持有链只能由本表回答。 # 若改为在 trans_borrow 上覆盖 current_holder,等于重演归还失忆症。 # # 2. **一期仅支持整单全量转交**:单行模型无法同时追踪两个持有人, # 部分转交会让 current_holder 语义撕裂(半单归 A、半单归 B)。 # 故 transfer_qty 一期恒等于 quantity - returned_quantity, # 服务层对部分转交直接拒绝。未来若需部分转交,需改为按 quantity 拆行。 # # 3. **不碰库存**:转交是纯持有权变更,实物不出入库,全程不得触碰 # available_quantity / stock_quantity。 # ============================================================================= # 转交状态机:发起只落 PENDING,接收人 accept 后才真正转移责任 TRANSFER_STATUS_PENDING = 'PENDING' # 待接收(主表 current_holder 未动) TRANSFER_STATUS_ACCEPTED = 'ACCEPTED' # 已接收(主表已转移) TRANSFER_STATUS_REJECTED = 'REJECTED' # 已拒绝(主表不动,责任仍在原持有人) class TransBorrowTransfer(db.Model): """借库转交流水:一行 = 一次转交动作,不做覆盖式更新。""" __tablename__ = 'trans_borrow_transfer' id = db.Column(db.Integer, primary_key=True) # 关联主表(不建 FK:与 trans_return 一致,台账必须能独立存活) # ★ 代表明细:一次转交要覆盖该单号下**多行**,单行 ID 表达不了覆盖范围, # 故覆盖范围以 borrow_no 为准,此列仅供追溯。 borrow_id = db.Column(db.Integer, nullable=False, index=True) # ★ 单据身份:accept 时据此批量更新该单全部未还明细的持有人 borrow_no = db.Column(db.String(100), index=True) # ★ 状态机:见文件顶部常量 status = db.Column(db.String(20), nullable=False, default=TRANSFER_STATUS_PENDING, index=True) # ★ 发起方看到「被拒绝」提醒并确认的时间。 # 被拒绝时物品责任仍在发起方手上 —— 他若不查列表就会误以为已经交接出去, # 责任链出现静默断点。故必须告知,且必须能标记「已告知」, # 否则发起方每次登录都收到同一条提醒,从提醒退化成骚扰。 reject_seen_at = db.Column(db.DateTime) # ★ 拒收原因独立成列。此前拼在 remark 里("...\n[拒绝原因] xxx"), # 前端拿到一坨字符串无法区分「转交备注」与「拒收原因」; # 靠字符串约定还原结构化信息既脆弱(用户自己也可能打出该标记), # 又没法参与查询统计。存储层能表达的东西不靠约定去猜。 reject_reason = db.Column(db.Text) # 转出方(= 转交前的 current_holder) from_user_id = db.Column(db.Integer) from_user_name = db.Column(db.String(100)) # 接收方(= 转交后的 current_holder) to_user_id = db.Column(db.Integer) to_user_name = db.Column(db.String(100)) # 一期恒等于 quantity - returned_quantity(整单全量转交) transfer_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) transfer_time = db.Column(db.DateTime, default=beijing_time) operator_name = db.Column(db.String(100)) # 执行转交的库管 remark = db.Column(db.Text) def to_dict(self): return { 'id': self.id, 'borrow_id': self.borrow_id, 'borrow_no': self.borrow_no, 'status': self.status, 'status_text': { TRANSFER_STATUS_PENDING: '待接收', TRANSFER_STATUS_ACCEPTED: '已接收', TRANSFER_STATUS_REJECTED: '已拒绝', }.get(self.status, self.status), # 仅供发起方「被拒绝」提醒使用,判断是否需要告知由 reject_seen_at 决定 'reject_seen': self.reject_seen_at is not None, 'reject_reason': self.reject_reason, 'from_user_id': self.from_user_id, 'from_user_name': self.from_user_name, 'to_user_id': self.to_user_id, 'to_user_name': self.to_user_name, 'transfer_qty': float(self.transfer_qty) if self.transfer_qty is not None else 0, 'transfer_time': self.transfer_time.strftime('%Y-%m-%d %H:%M:%S') if self.transfer_time else None, 'operator_name': self.operator_name, 'remark': self.remark, } # ============================================================================= # 借库归还流水 # ============================================================================= # 为什么需要它 # ------------ # trans_borrow 原先在部分归还时把 return_time / return_operator / # return_signature **逐次覆盖**,导致「谁在什么时候还了多少」只剩最后一次 # (出库退回模块的 TransReturn 已就同一问题另建流水,见本文件上方注释)。 # 本表按次记录归还动作,根治该失忆症。 # # ★ 主表字段的定位(未废弃,但降级): # returned_quantity / is_returned / status —— 仍是**累计快照**,聚合语义正确, # 列表页的「未还/已还」tab 判定依赖它们,继续维护; # return_time / return_operator / return_signature —— 降级为「最近一次归还」 # 展示快照,records.vue 的归还人/归还时间列依赖它们,继续刷新; # 逐次明细的**权威来源**是本表。 # # ★ returner_id 与 operator_name 是两个人: # returner_id = 实际把东西交回窗口的人,写入前已强校验 == current_holder_id; # operator_name = 经手办理还库的库管。 # ============================================================================= class TransBorrowReturn(db.Model): """借库归还流水:一行 = 一次归还动作。""" __tablename__ = 'trans_borrow_return' id = db.Column(db.Integer, primary_key=True) borrow_id = db.Column(db.Integer, nullable=False, index=True) # 关联 trans_borrow.id returner_id = db.Column(db.Integer, index=True) # 实际归还人(==current_holder) return_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0) return_time = db.Column(db.DateTime, default=beijing_time) operator_name = db.Column(db.String(100)) # 经手库管 def to_dict(self): return { 'id': self.id, 'borrow_id': self.borrow_id, 'returner_id': self.returner_id, 'return_qty': float(self.return_qty) if self.return_qty is not None else 0, 'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None, 'operator_name': self.operator_name, } class TransRepair(db.Model): __tablename__ = 'trans_repair' id = db.Column(db.Integer, primary_key=True, autoincrement=True) # 维修单号 (新增) repair_no = db.Column(db.String(50), nullable=True, unique=True) # 关联基础信息 (新增) base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=True) # SKU 保留 sku = db.Column(db.String(100)) # 物料名称 (独立录入时使用,非关联base_id) material_name = db.Column(db.String(200)) # 序列号SN (新增,用于单台追溯) serial_number = db.Column(db.String(100), nullable=True) # 来源追溯 (兼容旧数据) source_table = db.Column(db.String(50)) stock_id = db.Column(db.Integer) is_self_made = db.Column(db.Boolean, default=False) related_product_id = db.Column(db.Integer) # 入库/接收时间 arrival_date = db.Column(db.Date) # 维修状态 (新增) repair_status = db.Column(db.String(50), default='待检测') # 客户反馈 fault_description = db.Column(db.Text) # 预计修复时间 expected_repair_time = db.Column(db.String(100)) # 维修日志/结果 repair_result = db.Column(db.Text) # 维修人 repair_manager = db.Column(db.String(100)) # 出库交付时间 shipping_date = db.Column(db.Date) # 客户名/来源 related_contract_id = db.Column(db.String(100)) # 客户名称 (新增) customer_name = db.Column(db.String(100)) # 客户所在地 (新增) customer_location = db.Column(db.String(255)) # 成本与售价 cost_price = db.Column(db.Numeric(19, 4)) sale_price = db.Column(db.Numeric(19, 4)) # 数据隔离 (新增) company_id = db.Column(db.Integer, nullable=True) # 关联关系 base = db.relationship('MaterialBase', backref='repairs') def to_dict(self): return { 'id': self.id, 'repair_no': self.repair_no, 'base_id': self.base_id, 'sku': self.sku, 'material_name': self.material_name, 'serial_number': self.serial_number, 'source_table': self.source_table, 'stock_id': self.stock_id, 'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None, 'repair_status': self.repair_status, 'expected_repair_time': self.expected_repair_time, 'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None, 'is_self_made': self.is_self_made, 'related_product_id': self.related_product_id, 'related_contract_id': self.related_contract_id, 'customer_name': self.customer_name, 'customer_location': self.customer_location, 'repair_manager': self.repair_manager, 'fault_description': self.fault_description, 'repair_result': self.repair_result, 'cost_price': float(self.cost_price) if self.cost_price is not None else None, 'sale_price': float(self.sale_price) if self.sale_price is not None else None, 'company_id': self.company_id, } class TransScrap(db.Model): __tablename__ = 'trans_scrap' id = db.Column(db.Integer, primary_key=True) sku = db.Column(db.String(100)) source_table = db.Column(db.String(50)) stock_id = db.Column(db.Integer) quantity = db.Column(db.Numeric(19, 4)) reason = db.Column(db.Text) operator_name = db.Column(db.String(100)) operation_time = db.Column(db.DateTime, default=beijing_time) approver_name = db.Column(db.String(100)) 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 { 'id': self.id, 'sku': self.sku, 'source_table': self.source_table, 'stock_id': self.stock_id, 'quantity': float(self.quantity) if self.quantity is not None else None, 'reason': self.reason, 'operator_name': self.operator_name, 'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None, 'approver_name': self.approver_name, 'approval_status': self.approval_status, '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) # ★ 公司快照:退回发生时的所属公司。 # 不靠 join 推 —— 隔离判定的链路是 # trans_return → trans_outbound → (source_table, stock_id) → 库存表 → 物料主表 # 而库存行会被入库模块**物理删除**(实测 1077 条出库记录中已有 7 条悬空), # 链路一断,记录就会对普通用户静默消失。审计视图静默丢数据不可接受。 # 与 trans_defective_goods.company_name 同一处理方式。 # DDL 见 db_migrations/add_return_view_support.sql company_name = db.Column(db.String(255), index=True) 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, 'company_name': self.company_name, } # 状态机(三期升级): # 待处理 ──┬─→ 处理中 ──┬─→ 已回库(全部回库,无报废) # │ ├─→ 已报废(全部报废,无回库) # │ └─→ 已闭环(回库与报废混合,在管量归零) # └─(一次处置即为终态时,直接跳到对应的终态) # # ★ 三种终态而非单一「已闭环」,是为了让工作台一眼看出这批坏件的**去向**: # 修好回到库存了、还是被销毁了。混合处置无法用单一去向描述,才归到「已闭环」。 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, }