ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Flask和Vue的固定资产折旧与租赁维修管理系统开发实践

基于Flask和Vue的固定资产折旧与租赁维修管理系统开发实践 从事固定资产管理这一块的时间长了你会明显感觉到一个规律真正让管理员崩溃的往往不是资产数量多而是资产的“账”和“实物”对不上。去年我帮一家设备租赁公司做内部管理系统他们当时的资产台账还是靠一张超大Excel表月底财务手动算一次折旧租赁合同到期没到期全凭人脑记维修记录更是散落在聊天记录和手写单据里面一台设备从入库到报废经历过哪些事根本查不清楚。后来我用Python、Flask再加上Vue独立把这个固定资产折旧及租赁维修管理系统从零搭了出来从数据库设计、折旧算法到前端页面、部署上线前后大概花了六周目前这套系统已经稳定跑了大半年。这篇文章我会把整个项目的设计和实现过程原原本本讲一遍内容包括业务模块怎么拆、为什么选Flask而不是Django、折旧算法落地时怎么处理财务上的边界问题、Vue前端和Flask后端怎么联调以及开发时踩过的各种坑。如果你正准备做一个前后端分离的全栈项目想找一个能落地、业务逻辑不算太简单但又不会复杂到劝退的练手题目这套固定资产管理系统是非常合适的参考对象。1. 从Excel手工账到线上系统整体设计与技术选型1.1 先把业务模块拆清楚资产、折旧、租赁、维修很多初学者拿到项目题目就急着写代码这是大忌。我记得第一天接手这个项目时对方给我扔过来一摞纸质表格和几个Excel文件里面记录了公司两百多台打印机、服务器、工程设备、办公家具的基本信息。我做的第一件事不是建表而是把固定资产的完整生命周期画了出来资产采购入库 - 录入资产卡片 - 启用台账 - 每月计提折旧 - 使用过程中发生维修或租赁 - 资产处置报废。基于这条主流程系统功能被拆成五个模块资产台账模块记录资产编号、名称、分类、原值、启用日期、存放地点、使用人等基础信息提供新增、修改、查询、状态变更功能。折旧管理模块按月对资产计提折旧生成折旧明细记录展示累计折旧、账面净值。租赁管理模块管理出租合同、租赁状态、租金收取、到期提醒、归还登记。维修管理模块管理报修、派单、维修结果、费用登记、验收记录。统计报表模块按资产分类汇总数量与金额按月汇总折旧额和维修费用。这里有一点特别值得提醒非技术背景的用户往往不知道怎么提需求他们只会告诉你“我要一个系统能管理我的资产”。你需要自己把业务场景一个个问清楚比如“当月新增的资产什么时候开始算折旧”“资产出租期间还计提折旧吗”“维修费用超多少要提醒是否报废”。这些问题最后都会直接变成代码里的判断逻辑所以前期拆得越细后期写代码越顺。1.2 选型为什么是Flask而不是Django以及Vue在前端扮演的角色项目标题里同时出现了Flask和Django这其实反映了很多人选型时的纠结。我的选择是Flask理由非常实际Django虽然是全家桶自带Admin后台、ORM、认证、表单等功能但它的框架约束也更重。对于这种业务边界明确、接口数量可控的中小型管理系统Flask的轻量和灵活反而更能发挥优势。你想给资产状态做一个自定义的状态机流转Django Admin默认的那套逻辑反而要绕一下而在Flask里你只需要写几个蓝图、定义自己的路由和校验逻辑一切都在掌控之中。不过我也要说句公道话如果项目规模很大、团队已经习惯Django的开发方式那用Django完全没毛病关键看谁在维护。这个项目就我一个人开发Python的基础又本身就偏轻快用Flask能把开发速度拉满所以最终定了Flask SQLAlchemy的组合。前端选择Vue的原因也是一样的。Vue的学习曲线比较平缓模板语法直白配合Element Plus的表格、表单、日期选择器、弹窗这些成熟组件做管理后台的效率非常高。一个资产台账页面用el-table配合分页、搜索半天就能做出一个能看、能点的原型。而如果用纯JavaScript手写DOM操作光是表格渲染和状态切换就够你折腾三四天。开发工具我全程使用PyCharm。虽然有人觉得PyCharm太重但从项目第一天起就建好虚拟环境用虚拟环境管理依赖配合PyCharm的调试器打断点排查接口数据问题比在终端里print输出要高效太多。项目依赖我最后用requirements.txt固定了版本避免换一台电脑跑不起来。1.3 数据库表设计把资产状态流转落到字段上数据库是这个系统最核心的部分。设计时我遵循一个原则每个业务动作都必须能留下一张可追溯的记录表而不是只改一个状态字段就完事。整个系统最主要的是下面这四张表。资产表asset字段名类型说明idINT主键asset_noVARCHAR(50)资产编号唯一nameVARCHAR(100)资产名称categoryVARCHAR(50)资产分类original_valueDECIMAL(10,2)原值residual_rateDECIMAL(5,2)残值率如5.00表示5%service_lifeINT使用年限月start_dateDATE启用日期statusVARCHAR(20)状态在库/已出租/维修中/已报废locationVARCHAR(100)存放地点折旧记录表depreciation_record字段名类型说明idINT主键asset_idINT资产ID外键year_monthVARCHAR(7)计提月份如2024-06depreciation_amountDECIMAL(10,2)本月折旧额accumulated_depreciationDECIMAL(10,2)当月累计折旧额net_valueDECIMAL(10,2)当月账面净值is_latestTINYINT是否最新一条折旧记录租赁合同表lease_contract字段名类型说明idINT主键contract_noVARCHAR(50)合同编号asset_idINT出租资产IDcustomer_nameVARCHAR(100)承租方start_dateDATE起租日期end_dateDATE到期日期monthly_rentDECIMAL(10,2)月租金statusVARCHAR(20)合同状态生效中/已到期/已终止维修工单表repair_order字段名类型说明idINT主键order_noVARCHAR(50)工单编号asset_idINT报修资产IDreport_timeDATETIME报修时间fault_descTEXT故障描述handlerVARCHAR(50)维修人statusVARCHAR(20)状态待派单/维修中/待验收/已完成/已关闭costDECIMAL(10,2)维修费用finish_timeDATETIME完成时间这里特别说一下折旧记录表里的is_latest字段。固定资产的折旧记录每个月都会产生一条如果要看某台资产当前净值最直接的方法是把该资产所有折旧记录累加但这样每次查询都要做一次聚合运算资产多起来后接口会越来越慢。加上is_latest字段后每次生成新的折旧记录时先把该资产所有旧记录置为0再把最新一条置为1查净值时只要带is_latestTrue条件就能瞬间拿到结果。这个字段是典型的“以空间换时间”管理类系统的开发日常里很实用。2. 折旧模块把财务算法写成可复用代码2.1 折旧计算规则与手算示例折旧计算是这个系统里技术含量最高的一块也是财务最看重的地方。固定资产折旧的常见算法有直线法、工作量法、双倍余额递减法、年数总和法四种。对于这家公司来说所有设备都采用直线法因为直线法计算简单、分摊均匀也是中小企业用得最多的方法。直线法的核心计算公式是残值 原值 × 残值率应计提总额 原值 - 残值月折旧额 应计提总额 ÷ 使用年限月举一个具体的例子。一台打印机原值12000元残值率5%使用年限5年也就是60个月。那么残值就是12000乘以0.05等于600元应计提总额就是12000减600等于11400元分摊到60个月每个月折旧额就是11400除以60等于190元。这台打印机使用满60个月后累计折旧正好是11400元账面净值就是12000减去11400等于600元刚好等于残值。这里有一个新手容易忽略的细节折旧记录为什么要按月而不是按年存因为资产很可能在年中发生处置、维修、报废等操作处置时你必须知道截至当前月份这台资产到底提了多少折旧如果只有年口径的数据处置月份前的净值根本算不出来财务那边会非常头疼。2.2 用Flask实现一键计提折旧的完整流程折旧的代码我放在一个独立的depreciation_service.py文件里不写在路由函数中方便后续扩展成定时任务。这个过程分为四步第一步查出所有状态为“正常使用”的资产 第二步判断当前月份是否已经生成过折旧记录如果生成过则直接跳过防止重复计提 第三步对每台资产计算当月折旧额写入折旧记录表 第四步更新资产的累计折旧和净值。代码片段如下# services/depreciation_service.py from decimal import Decimal from datetime import datetime from extensions import db from models.asset import Asset from models.depreciation_record import DepreciationRecord def get_depreciation_amount(original_value: Decimal, residual_rate: Decimal, service_life_months: int, accumulated: Decimal) - Decimal: 计算当前月折旧额 residual original_value * residual_rate / Decimal(100) total_depreciation_base original_value - residual if service_life_months 0: return Decimal(0.00) monthly (total_depreciation_base / service_life_months).quantize(Decimal(0.00)) # 处理最后一个月四舍五入产生的差额 remaining total_depreciation_base - accumulated if remaining monthly: return remaining.quantize(Decimal(0.00)) return monthly然后写生成折旧记录的流程def run_monthly_depreciation(year_month: str): # 判断本月是否已经计提过 exists DepreciationRecord.query.filter_by(year_monthyear_month).first() if exists: return {message: 本月已计提折旧, count: 0} assets Asset.query.filter(Asset.status.in_([在库, 已出租, 维修中])).all() count 0 for asset in assets: last_record DepreciationRecord.query.filter_by( asset_idasset.id, is_latestTrue ).first() accumulated last_record.accumulated_depreciation if last_record else Decimal(0.00) amount get_depreciation_amount( asset.original_value, asset.residual_rate, asset.service_life, accumulated ) new_accumulated accumulated amount new_net_value asset.original_value - new_accumulated if last_record: last_record.is_latest False db.session.add(DepreciationRecord( asset_idasset.id, year_monthyear_month, depreciation_amountamount, accumulated_depreciationnew_accumulated, net_valuenew_net_value, is_latestTrue )) count 1 db.session.commit() return {message: 计提完成, count: count}这段代码里最需要关注的是get_depreciation_amount里的最后一个月处理逻辑。因为每个月折旧额都会被四舍五入到分两位小数比如11400除以60等于190这不涉及精度问题但如果换成原值10000、残值率3%、使用年限36个月呢残值300应计提9700除以36等于269.4444...每月的折旧额没法整除肯定会四舍五入。几个月攒下来累计折旧会慢慢偏离应计提总额。如果不做最后的差额修正资产到报废时的账面净值永远不会等于残值财务对账就会差出几块钱到几十块钱。这个“尾差”问题是我在跟财务核对第一个月数据时发现的当时他们拿计算器一笔一笔算最后发现系统里净值和他们的Excel表差了1.2元。从那以后我就在方法里加上了remaining monthly的判断把差额在最后一个月一次性补上。2.3 折旧模块的几个边界场景当月新增资产当月不提折旧次月开始计提。这是会计准则里的常见规则。实现时只要在查询资产的时候加一个条件asset.start_date 当前月份的第一天。我把这个判断放在接口查询层而不是折旧算法内部这样算法可以保持纯粹只管计算。维修期间照常计提折旧。固定资产的折旧是基于账面原值和使用寿命来计提的维修期间资产并不停止衰老所以代码里我就是把“维修中”状态的资产同样纳入折旧提取范围。出租资产由出租方计提折旧。这是一个多租户系统里很容易踩坑的点一旦资产被出租承租方不会去提折旧账面上应该还是在出租方这里。在这个单公司的系统里出租资产的折旧照提不误所以我把“已出租”状态也纳入折旧范围。残值率为0怎么办。有些低值资产公司可能设置残值率为0算法里分母减0没有任何问题不用额外处理。另外要强调一点金额计算千万不要用Python的float类型。第一次实现时我用float计算结果显示为190.0表面看起来没问题但两个float求和时会出现类似0.30000000000000004这样的精度问题财务系统一分钱都不能差。后来我把所有金额字段都换成了SQLAlchemy的Numeric(10,2)类型价格变量统一用decimal.Decimal字符串转Decimal时再注意保留两位小数预算精度问题才彻底消除。这个习惯从一开始就该养成后面返工的代价真的很大。3. 租赁与维修模块用状态机驱动业务流转3.1 租赁资产的状态机设计固定资产管理最难控制的不是折旧而是资产状态的变化。同一个资产能不能被同时出租给两个客户正在维修中的设备能不能被租赁这些问题必须用代码约束住而不是靠管理员自觉。我在系统里给每台资产定义了五个状态在库、已出租、维修中、已报废、已处置。资产只能在这几个状态之间按规则流转在库 - 已出租创建租赁合同时触发已出租 - 在库合同到期且资产被归还时触发在库 - 维修中创建维修工单且维修人接单时触发维修中 - 在库维修完成并验收通过时触发任意状态 - 已处置资产报废或卖出时触发我专门写了一个状态流转的服务函数任何地方要改变资产状态都要经过这个函数而不是在各处直接修改status字段。# services/asset_state.py from models.asset import Asset, AssetStatus from extensions import db def mark_asset_leased(asset: Asset): 资产从在库变更为已出租 if asset.status ! AssetStatus.IN_STORAGE: raise ValueError(只有状态为在库的资产才能出租) asset.status AssetStatus.LEASED db.session.add(asset) def mark_asset_in_repair(asset: Asset): 资产从在库变更为维修中 if asset.status ! AssetStatus.IN_STORAGE: raise ValueError(维修只能针对在库资产发起) asset.status AssetStatus.REPAIRING db.session.add(asset) def finish_repair_and_back(asset: Asset): 维修完成资产回到在库 if asset.status ! AssetStatus.REPAIRING: raise ValueError(资产当前不是维修中状态) asset.status AssetStatus.IN_STORAGE db.session.add(asset)这个设计直接避免了一个真实事故当时系统还没上线线下管理的时候同一台空压机被同时租给了两个工地闹出纠纷后客户专门打电话来投诉。用状态机管理后出租的前提状态被硬编码状态不对根本不可能发起租赁流程这种情况彻底杜绝。3.2 维修工单的完整生命周期维修模块是我最后写的因为它的状态比租赁更复杂。我定义了一个五状态的维修工单流待派单报修登记完成还没有指定维修人维修中维修人接单开始维修待验收维修人员提交完成等待资产管理员验收已完成验收通过已关闭超过一定时间未验收或验收不通过返回重修的旧工单被关闭这个流程和现实中报修场景是对应的。用户报修时先填写资产编号、故障描述、报修时间系统生成工单号。管理员看到待派单选人维修人接单后变成维修中维修完成提交内容和费用后变成待验收。这里我加了一个业务规则如果维修费用超过资产原值的30%系统自动弹窗提示管理员“修不划算建议报废”。30%这个阈值是跟财务确认的不同公司可以做成配置项。维修工单的核心价值在于费用归集。到月底系统可以很方便地按资产分类汇总当月的维修费用对比折旧费用这样管理层就知道什么类型的老旧设备在持续“吃钱”为后续淘汰采购决策提供依据。3.3 租赁和维修接口怎么写又快又稳我把租赁和维修的接口都放到了独立的蓝图下使用了Flask的Blueprint来组织路由。一个关键的接口是创建租赁合同它的逻辑并不只是插入一条合同记录还要同时把资产状态改为已出租这两步必须在一个事务里完成否则就可能出现合同建了但资产状态没变的脏数据。# blueprints/lease.py from flask import Blueprint, request, jsonify from extensions import db from models.asset import Asset from models.lease_contract import LeaseContract from services.asset_state import mark_asset_leased lease_bp Blueprint(lease, __name__, url_prefix/api/lease) lease_bp.post(/) def create_lease(): data request.get_json() asset_id data.get(asset_id) asset Asset.query.get(asset_id) if not asset: return jsonify({error: 资产不存在}), 404 # 状态校验不通过会抛出ValueError mark_asset_leased(asset) contract LeaseContract( contract_nodata[contract_no], asset_idasset_id, customer_namedata[customer_name], start_datedata[start_date], end_datedata[end_date], monthly_rentdata[monthly_rent], status生效中 ) db.session.add(contract) db.session.commit() return jsonify({message: 租赁创建成功, contract_id: contract.id}), 201这里最关键的是mark_asset_leased和合同创建在同一个事务里。即使db.session.commit()之前某一步抛了异常前面的修改也会被回滚不会出现合同保存成功但资产状态没变的尴尬情况。开发这类管理系统的经验就是凡是涉及多个表变化的操作一定要确保它们在同一事务里提交或回滚。4. Vue前端页面与Flask联调4.1 Vue工程搭建和组件规划前端我用的是Vue 3 Vite Element Plus组合。如果你是新手我强烈建议用Vite而不是Vue CLI初始化工程Vite启动速度快配置文件简洁用起来非常顺手。初始化命令很简单npm create vitelatest asset-frontend -- --template vue cd asset-frontend npm install npm install axios element-plus vue-router4安装Element Plus后为了全局引入方便我直接在main.js里注册了import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router const app createApp(App) app.use(ElementPlus) app.use(router) app.mount(#app)路由规划我采用的是管理后台最常见的侧边栏布局模式主体区域根据路由切换不同页面。路由表如下/dashboard统计大盘展示资产总数、资产总值、本月折旧、本月维修费用/assets资产台账包含资产列表、新增资产、编辑资产、状态变更/depreciation折旧管理展示折旧记录、一键计提按钮、折旧明细表/lease租赁管理出租合同列表、新建合同、到期提醒/repairs维修管理维修工单列表、报修登记、派单操作前端页面的开发顺序我建议先做资产台账它是最基础的模块后续租赁和维修都依赖资产数据。4.2 资产台账页面的实现思路资产台账页面用Element Plus的el-table做主体顶部放一个搜索栏支持按资产编号、名称、分类搜索。由于数据量可能过万后端我做了分页前端用el-pagination组件配合current-page和page-size两个变量来控制请求参数。状态显示这一块有个小技巧。资产的五种状态在表格里用el-tag展示不同状态配不同颜色视觉效果一目了然。el-table :datatableData border stripe el-table-column propasset_no label资产编号 width140 / el-table-column propname label资产名称 min-width160 / el-table-column propcategory label分类 width120 / el-table-column proporiginal_value label原值 width110 / el-table-column label净值 width110 template #default{ row }{{ row.net_value }}/template /el-table-column el-table-column label状态 width100 template #default{ row } el-tag :typestatusTagType(row.status){{ row.status }}/el-tag /template /el-table-column el-table-column label操作 width180 fixedright template #default{ row } el-button link typeprimary clickopenDetail(row)详情/el-button el-button link typewarning clickopenEdit(row)编辑/el-button /template /el-table-column /el-table新增和编辑资产我用一个el-dialog弹窗来承载表单表单里使用el-form的规则校验功能资产编号必填且唯一、原值必须是数字、残值率范围在0到100之间。弹窗提交时调用axios的POST或PUT接口成功后刷新表格数据并关闭弹窗。这个交互模式在管理后台里是最常见的掌握了这一个页面后面的租赁和维修页面基本就是复制改造的工作。4.3 前后端联调容易被忽略的细节前端页面写好后跟Flask后端联调时会遇到几个高频问题我一个个说。跨域问题。Flask后端跑在5000端口Vue开发服务器跑在5173端口两者域名不同浏览器默认会拦截跨域请求。我的解决办法是开发环境用Vite的proxy代理在vite.config.js里加一行配置server: { proxy: { /api: { target: http://localhost:5000, changeOrigin: true } } }这样前端请求/api/asset/list时Vite会自动转发到5000端口浏览器看到的请求是同源的不会触发CORS拦截。生产部署时再用Nginx做一层反向代理把前端静态资源和后端API放在同一个域名下同样不需要额外处理CORS。这是我一直推荐的联调方式比在后端装flask-cors、开放所有跨域请求要干净得多。时间格式问题。Flask的datetime字段默认序列化出来是2024-06-15T10:30:00这种ISO格式而Element Plus的日期选择器传过来的格式是2024-06-15。如果没处理表单里填开始日期然后直接存库就会看到数据库里多出一截T00:00:00。我在后端写了一个统一的时间处理函数接口返回时把datetime格式化成YYYY-MM-DD HH:mm:ss接收前端参数时再统一解析。这种约定一旦定下来前后端都按这个格式走联调时能省下很多不必要的沟通成本。金额显示格式问题。我的建议是前端只负责展示和收集金额不要做任何加减乘除。表格里显示的金额统一调用toFixed(2)保留两位小数输入框里的金额组件限制只能输入数字和小数点提交时后端再校验精度。如果前端直接拿两个金额做加法float运算的精度问题会在浏览器里同样出现而且很难排查。最安全的方式就是把计算逻辑全部放在后端Python里用Decimal处理。5. 开发中踩过的坑和排查心得5.1 数据库Decimal精度坑这个坑我在折旧模块里已经提过但实在重要值得单独再讲一遍。我最初把资产原值、折旧额这些字段定义成Float型跑了一个月的数据后财务说系统里的累计折旧和Excel差了一分钱。排查后发现是多个浮点数累加时产生了精度误差比如190.01加190.01显示为380.02但再往下累加时某一步会变成760.0400000000001。用Numeric(10,2)类型定义所有金额字段后这个问题彻底消失。如果你正在用SQLAlchemy字段定义示例是from sqlalchemy import Numeric original_value db.Column(Numeric(10, 2), nullableFalse) depreciation_amount db.Column(Numeric(10, 2), nullableFalse)5.2 重复计提折旧的幂等处理运行计提折旧接口时如果前端按钮被双击可能同时发出两个请求或者管理员不小心点了两次计提按钮就会生成两遍同月的折旧记录。我的解决方案是给depreciation_record表加了一个联合唯一索引(asset_id, year_month)数据库层面保证同一个资产在同一个月份只能有一条折旧记录。然后再在业务代码里做前置判断如果本月已经计提过直接返回提示信息。这两层保险缺一不可。5.3 并发修改资产状态公司有两名管理员同时操作时可能出现一个管理员正在把某台设备设置为出租另一个管理员同时在把它设置为维修中。如果两个请求几乎同时到达后一个请求不一定能看到前一个请求已经修改完的状态最终数据库里的状态就取决于最后提交的那一个但租赁合同和维修工单可能都生成了数据就乱了。解决方案是在更新资产状态时使用条件更新只有状态匹配才更新成功from sqlalchemy import update result db.session.execute( update(Asset) .where(Asset.id asset_id, Asset.status 在库) .values(status已出租) ) if result.rowcount 0: raise RuntimeError(资产状态已变化请刷新页面后再操作)这个写法利用数据库行锁的特性并发时只有一个请求能更新成功另一个会因为rowcount为0而失败从根上避免了状态冲突。这个技巧在整个系统的状态变更接口里都被我用上了。5.4 维修图片上传的附件路径问题维修工单需要支持上传故障照片我在本地开发时把上传目录写成了相对路径./uploads开发环境一切正常。后来把系统部署到服务器上通过Nginx映射域名访问后图片全部打不开。排查发现Flask从不同工作目录启动时相对路径解析出的实际位置完全不同。最后我改成在配置文件中显式指定UPLOAD_FOLDER为绝对路径比如/data/asset_system/uploads并且把图片的访问也通过Nginx映射到一个固定URL上这个问题才算彻底解决。这一点也是朋友跟我提到过的高频坑Flask项目在Windows本地开发没问题部署到Linux服务器后附件路径就报错基本都是相对路径和静态文件映射的问题。建议项目一开始就用绝对路径配置上传目录不要为了省事写相对路径。结尾说到最后我想分享一个实际开发中的小体会固定资产管理系统虽然听起来不复杂但真正把它做稳定难点不在CRUD而在业务规则是否严密。折旧的尾差要不要处理、租赁状态被并发修改时怎么办、维修费用超阈值要不要提醒这种细节如果不是跟财务、资产管理员反复确认靠拍脑袋是做不出来的。我在开发过程中咨询了很多一线使用者的意见很多人提的第一句话不是“我要什么功能”而是“我原来那个Excel表格怎么用的”从他们的表格里我反而读到了最真实的业务逻辑。如果你也打算用Flask和Vue做类似的系统我建议你从资产台账和折旧模块入手先把这两个基础模块跑通再加上租赁和维修功能一步一个脚印来。这个项目的代码结构和业务拆分完全可以作为毕业设计或者公司内部工具的原型。最后再给你一个实用技巧上线前拿两个月真实历史数据跑一遍把系统计算结果和Excel手工计算结果逐条对比能发现不少隐藏问题。这套系统的稳定很大程度上就是靠这种“笨功夫”换来的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进