
1. 项目背景为什么一个固定资产系统会让财务和行政吵起来固定资产折旧及租赁维修管理系统名字听着像是个内部OA的小模块但真做起来会发现它是典型的业务规则比技术难的项目。我接过不少类似的管理系统开发凡是涉及固定资产的几乎无一例外会踩到同一个矛盾财务要的是折旧数据准确、报表可追溯行政/后勤要的是资产台账清晰、维修记录完整、租赁合同到期有人提醒。两边需求一叠加技术本身反而不是最难的难的是把业务规则翻译成数据结构和流程。这个项目用到的技术栈很明确Python Flask 做后端 APIVue 做前端页面开发工具用 PyCharm。标题里虽然带了 django 这个词但核心框架是 Flaskdjango 更多是作为 Python Web 开发领域经常被一起讨论的对比框架出现。我在做技术选型时也确实认真对比过 Flask 和 Django后面会专门讲我的取舍理由。先把这个系统要解决的核心问题拆开看固定资产台账管理资产从入库、领用、调拨到报废的全生命周期记录。折旧计算按月生成折旧凭证支持不同折旧方法并产出报表。租赁管理资产对外出租时合同、租金、到期提醒、归还登记。维修管理资产损坏后的报修、派工、维修费用登记、维保记录。这套系统的目标用户是中小型企业的行政、财务、资产管理岗以及接此类定制开发的外包团队。如果你想练手做一个前后端分离的 Flask Vue 完整项目或者你刚好接到了一个资产管理类的需求这篇内容可以作为你的参考蓝本。我习惯把这类项目称作表单密集型系统——没有高并发、没有复杂算法但充满了状态流转、校验规则和数据一致性要求。做得好的关键是把你对业务的理解沉淀到数据模型和接口设计里而不是堆页面。2. 技术选型为什么用 Flask Vue以及 PyCharm 下的工程搭建2.1 Flask 与 Django 的取舍标题里同时出现了 flask 和 django很容易让人纠结。我在这个项目里最终选了 Flask理由有三个第一项目规模决定了框架成本。固定资产系统属于典型的中小型管理软件API 数量大概在 30 到 50 个之间业务复杂度中等。Flask 的轻量特性让我们可以自由组织目录结构不受 Django 的app 应用约束。Django 自带 Admin、ORM、Migrate 等全家桶能力但在这种项目里一半以上的内置功能用不到反而要花时间绕开它的默认约定。第二前后端分离架构下Flask 只需要专注提供 JSON API。Django 强大的模板系统在前后端分离的模式下没有发挥空间Flask 的蓝图Blueprint机制用来划分模块已经足够清晰。第三团队技术栈的延续性。如果团队后续想快速开发小工具或微服务Flask 的迁移成本更低代码量更小心智负担也更轻。当然Django 并非不能做。如果你特别依赖 Django Admin 直接生成管理后台或者项目后续会有复杂的权限体系Django 自带的 auth 和 permission 确实比 Flask 生态更成熟选 Django 完全合理。这个项目里我选 Flask是因为我更看重接口的灵活性和代码的可读性。2.2 前端用 Vue 的原因Vue 在这类管理系统中几乎是标配原因很简单数据双向绑定让表单开发效率极高资产录入页面的字段动辄二十多个用原生 JS 操作 DOM 会写到怀疑人生。Element UI 或 Element Plus 组件库自带表格、表单、日期选择器、弹窗和后台管理系统的界面需求高度匹配。Vue 的生态对新手友好教程多、踩坑解决方案多团队招人也好招。前后端分离的模式下Vue 跑在 8080 端口Flask 跑在 5000 端口二者通过 HTTP 通信。开发阶段用 Vite 或 Vue CLI 的代理转发解决跨域生产环境用 Nginx 统一入口。这个架构可以说是目前中小型管理系统的标准答案。2.3 PyCharm 下的工程目录规划PyCharm 作为 Python 的 IDE对这个项目最大的帮助在于虚拟环境管理、调试断点和数据库工具面板。我习惯在 PyCharm 里新建项目时直接创建 venv 虚拟环境配合 requirements.txt 做依赖管理。后端目录结构建议这样组织asset_management/ ├── app.py # 应用入口 ├── config.py # 配置文件 ├── models/ │ ├── __init__.py │ ├── asset.py # 资产模型 │ ├── depreciation.py # 折旧记录模型 │ ├── lease.py # 租赁模型 │ └── repair.py # 维修模型 ├── api/ │ ├── __init__.py │ ├── asset_api.py # 资产相关接口 │ ├── depreciation_api.py # 折旧相关接口 │ ├── lease_api.py # 租赁相关接口 │ └── repair_api.py # 维修相关接口 ├── utils/ │ ├── auth.py # JWT 鉴权 │ └── response.py # 统一响应格式 ├── requirements.txt └── run.py前端单独建一个 Vue 项目按 views、components、api、router 四个目录组织。这里有一个我踩过坑后的建议前后端项目不要放在同一个目录下混合管理而是同级放比如backend/和frontend/两个目录并列。否则 Git 提交时很容易误操作而且 PyCharm 打开混合目录时索引速度会明显变慢。3. 数据库建模资产、折旧、租赁、维修四张表的血缘关系3.1 从资产卡片反推字段设计固定资产系统的核心是资产卡片所有业务都围绕资产 ID 展开。我在设计数据表时不是直接上手建表而是先模拟用户录入一张资产卡片的全过程列出所有必填和选填字段。一张典型的资产卡片包含资产基础信息资产编码、名称、分类办公设备/生产设备/运输工具/房屋建筑等、规格型号、供应商、存放地点、使用部门、保管人。财务信息原值、预计净残值率、预计使用年限、入账日期、折旧方法。状态信息当前状态在库/在用/出租/维修中/已报废、状态变更时间。对应到数据库模型我设计了Asset表class Asset(db.Model): __tablename__ asset id db.Column(db.Integer, primary_keyTrue) asset_code db.Column(db.String(50), uniqueTrue, nullableFalse, indexTrue) name db.Column(db.String(100), nullableFalse) category db.Column(db.String(50), nullableFalse) # 资产分类 spec db.Column(db.String(100)) # 规格型号 supplier db.Column(db.String(100)) location db.Column(db.String(100)) department db.Column(db.String(50)) custodian db.Column(db.String(50)) # 保管人 original_value db.Column(db.Numeric(12, 2), nullableFalse) # 原值 residual_rate db.Column(db.Float, default0.05) # 预计净残值率 useful_life db.Column(db.Integer, nullableFalse) # 预计使用年限月 purchase_date db.Column(db.Date, nullableFalse) # 入账日期 depreciation_method db.Column(db.String(20), defaultstraight_line) # 折旧方法 status db.Column(db.String(20), defaultin_stock) # 资产状态 created_at db.Column(db.DateTime, defaultdatetime.utcnow)这里有两个容易被忽视的设计细节资产编码必须唯一且带索引。资产管理最频繁的操作就是根据编码查资产这个字段不建索引数据量过万后接口响应会明显变慢。金额字段用Numeric(12, 2)而不是Float。财务数据最忌讳浮点精度问题0.1 0.2 的经典错误在折旧累计计算中会被放大。Numeric在 SQLAlchemy 中对应数据库的 DECIMAL 类型能精确控制小数位。3.2 折旧记录表为什么要单独建一张表而不是只算一个字段很多初学者会把累计折旧设计成 Asset 表里的一个字段每月更新一次。这种设计的最大问题是你丢失了历史。三个月后想查某资产某月折旧了多少数据已经覆盖掉了。正确的做法是单独建DepreciationRecord表每条记录代表某个资产某个月的折旧情况class DepreciationRecord(db.Model): __tablename__ depreciation_record id db.Column(db.Integer, primary_keyTrue) asset_id db.Column(db.Integer, db.ForeignKey(asset.id), nullableFalse, indexTrue) period db.Column(db.String(7), nullableFalse) # 折旧期间格式2025-06 depreciation_amount db.Column(db.Numeric(12, 2), nullableFalse) # 本月折旧额 cumulative_depreciation db.Column(db.Numeric(12, 2), nullableFalse) # 累计折旧 net_value db.Column(db.Numeric(12, 2), nullableFalse) # 净值 created_at db.Column(db.DateTime, defaultdatetime.utcnow) asset db.relationship(Asset, backrefdb.backref(depreciation_records, lazydynamic)) __table_args__ (db.UniqueConstraint(asset_id, period, nameuniq_asset_period),)asset_id和period加了联合唯一约束这是为了保证同一个资产在同一个月不会生成两条折旧记录。批量折旧生成时如果重复执行这个约束就是最后一道防线。3.3 租赁与维修的状态字段设计租赁表的核心是追踪合同状态。我设计了LeaseContract表字段包括合同编号、资产 ID、承租方、租赁开始日期、结束日期、月租金、押金、合同状态生效中/已到期/已终止、备注等。维修表RepairRecord则围绕工单流转来设计报修人、报修日期、故障描述、维修方、维修类型内部/外部、预计费用、实际费用、维修开始日期、完成日期、维修结果、验收状态。这两张表有一个共同的设计要点状态字段不要用自由字符串而是用常量枚举。我在项目里定义了一个AssetStatus类class AssetStatus: IN_STOCK in_stock # 在库 IN_USE in_use # 在用 LEASED leased # 出租中 REPAIRING repairing # 维修中 SCRAPPED scrapped # 已报废业务规则里有大量当资产处于某个状态时才能执行某个操作的约束。比如资产已报废就不能再发起报修资产出租期间不能调拨。这些规则看似简单但散落在代码里就会变成一个个 if 判断一旦状态多了管理起来非常痛苦。我的做法是把状态流转集中到一个工具函数里校验ALLOWED_TRANSITIONS { AssetStatus.IN_STOCK: [AssetStatus.IN_USE, AssetStatus.LEASED, AssetStatus.SCRAPPED], AssetStatus.IN_USE: [AssetStatus.IN_STOCK, AssetStatus.REPAIRING, AssetStatus.SCRAPPED], AssetStatus.LEASED: [AssetStatus.IN_STOCK, AssetStatus.REPAIRING], AssetStatus.REPAIRING: [AssetStatus.IN_USE, AssetStatus.IN_STOCK], AssetStatus.SCRAPPED: [], } def validate_transition(asset, target_status): allowed ALLOWED_TRANSITIONS.get(asset.status, []) if target_status not in allowed: raise ValueError(f资产状态不允许从 {asset.status} 变更为 {target_status})这个配置表的好处是业务规则一目了然测试也好写。后续如果要加状态比如借用中只需要在配置里加一行。4. 折旧计算模块业务规则和代码实现4.1 四种折旧方法的业务理解固定资产折旧会计上有几种常用方法这个系统里我实现了其中两种最常用的直线法和双倍余额递减法。其他方法工作量法、年数总和法其实也是公式变换理解了核心逻辑后加代码很容易。直线法的公式是月折旧额 (原值 - 预计净残值) / 预计使用年限(月)预计净残值 原值 × 预计净残值率。大多数企业会设置 5% 的残值率也就是说一台原值 12000 元的设备最终要折旧的总额是 11400 元。双倍余额递减法则是加速折旧前期折旧多、后期少。公式是年折旧率 2 / 预计使用年限(年)但在实际开发中纯按公式算会出现一个问题最后一个月的净值会被扣成负数。所以需要在净值低于某个阈值时切换到直线法把剩余净值在剩余月份内平均摊销。这也是双倍余额递减法后期改直线法这个会计惯例的由来。4.2 直线法折旧的 Python 实现我写了一个calculate_depreciation函数入参是资产信息和折旧月份返回该月应提折旧def calculate_straight_line_depreciation(asset, period): original_value float(asset.original_value) residual_value original_value * asset.residual_rate depreciable_base original_value - residual_value monthly_amount round(depreciable_base / asset.useful_life, 2) return monthly_amount这里有个细节useful_life存的是月份数。比如预计使用年限 5 年就存 60。如果存的是年数计算时就要乘 12容易在边界判断上出问题。但仅仅一个函数不够因为资产不是买了当月就开始折旧的。按会计准则固定资产的折旧通常从入账的次月开始计提。所以生成折旧记录时需要判断如果purchase_date所在的月份等于period则这个月不生成记录从下个月开始。4.3 批量生成折旧记录的完整逻辑实际业务中不会一台台资产去算折旧而是每月末跑一次批量任务把当时状态为在库和在用的资产全部扫描一遍生成当月折旧。核心代码如下def generate_monthly_depreciation(period): assets Asset.query.filter( Asset.status.in_([AssetStatus.IN_STOCK, AssetStatus.IN_USE]) ).all() created_count 0 for asset in assets: # 判断是否已计提完毕 last_record DepreciationRecord.query.filter_by(asset_idasset.id)\ .order_by(DepreciationRecord.period.desc()).first() if last_record and float(last_record.net_value) 0: continue # 判断是否已生成过该月记录 existing DepreciationRecord.query.filter_by( asset_idasset.id, periodperiod ).first() if existing: continue monthly_amount calculate_straight_line_depreciation(asset, period) cumulative (last_record.cumulative_depreciation monthly_amount if last_record else monthly_amount) net_value float(asset.original_value) - cumulative # 最后一期修正避免负数净值 if net_value 0: net_value 0 monthly_amount float(asset.original_value) - float(last_record.cumulative_depreciation) record DepreciationRecord( asset_idasset.id, periodperiod, depreciation_amountmonthly_amount, cumulative_depreciationcumulative, net_valuenet_value ) db.session.add(record) created_count 1 db.session.commit() return created_count这里的最后一期修正逻辑非常关键。因为monthly_amount四舍五入到分之后最后一期的累计折旧不一定恰好等于应折旧总额可能多一分也可能少一分。所以要在净值小于 0 时反向修正当月折旧额。4.4 折旧报表的接口实现前端需要一个趋势图或者列表来展示每个月折旧总额。对应接口的 SQL 很简单records db.session.query( DepreciationRecord.period, db.func.sum(DepreciationRecord.depreciation_amount).label(total_depreciation) ).group_by(DepreciationRecord.period).all()但这里我踩过一个坑period 字段是字符串类型时排序会按字典序而不是时间序。比如2025-10会排在2025-9前面导致前端折线图的横轴错乱。解决方式有两个一是把 period 存成 Date 类型只在展示时格式化成YYYY-MM二是查询时按period排序后在 Python 里用sorted(records, keylambda r: r.period)按字典序排确实坑因为2025-9和2025-10字典序上2025-10更靠前但2025-9后面没有10所以其实没问题反而是2025-1到2025-9的排序会乱。我最终选择了把 period 转为YYYY-MM-DD的 Date 类型固定每月第一天这样排序不会出错。5. 租赁与维修管理状态机为核心的业务闭环5.1 租赁管理的完整流程设计租赁业务看着简单其实涉及多个环节登记租赁合同选资产、填承租方、租期、租金合同生效时资产状态从在库变为出租中每个月可以登记租金收款记录租赁到期前自动提醒看板展示即将到期的合同租期结束办理归还资产状态回到在库在这个流程里最容易出问题的环节是资产状态同步。假设资产 ID 10086 被租赁了合同创建成功但资产状态没改后面别人就可能再次把这个资产租出去。我的做法是在同一个事务里完成合同创建和资产状态变更def create_lease_contract(data): asset Asset.query.get(data[asset_id]) if asset.status ! AssetStatus.IN_STOCK: raise ValueError(该资产当前不可租赁) contract LeaseContract(**data) db.session.add(contract) asset.status AssetStatus.LEASED db.session.commit() return contract这样设计的好处是数据库事务的原子性保证了合同存在且资产被占用这两个事实永远同时成立。5.2 维修工单的生命周期管理维修流程更加琐碎。一个报修工单从创建到归档需要经历报修登记填写资产、故障描述、报修人。派单指定维修方内部人员或外部供应商填写预计费用。维修中维修方执行维修可更新进度备注。验收资产使用人确认维修结果填写实际费用。归档工单关闭资产状态恢复。对应到代码里RepairRecord表有一个stage字段取值范围是pending待派单、repairing维修中、completed已完成、closed已归档。前端每个操作按钮的显隐完全由当前stage决定。5.3 Vue 前端如何与后端接口联动以报修功能为例前端页面流程是在资产列表页点击报修按钮弹窗显示报修表单。表单提交到POST /api/repair后端返回工单 ID。工单列表页根据stage展示不同操作按钮。这里我想强调一个前后端协作的细节后端返回的状态码和数据结构必须统一格式。我在utils/response.py里封装了统一的响应格式def success(dataNone, message操作成功): return {code: 0, message: message, data: data} def error(message操作失败, code400): return {code: code, message: message, data: None}前端在api/request.js里封装 axios 拦截器统一处理codeservice.interceptors.response.use( (response) { const res response.data if (res.code ! 0) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, (error) { ElMessage.error(error.response?.data?.message || 网络错误) return Promise.reject(error) } )这样前端业务代码里不用到处写 try-catch报错提示也统一了。很多小项目的接口返回格式五花八门——有返回裸对象的、有包一层的、错误提示有的在message有的在msg——联调的时候会让你崩溃到怀疑人生。5.4 到期提醒和待办看板的实现这个系统有个很实用的功能首页看板。展示三类信息本月即将到期的租赁合同结束日期在 30 天内且状态为生效中待派单的维修工单本月折旧总额对应的接口app.route(/api/dashboard/summary) def dashboard_summary(): today date.today() thirty_days_later today timedelta(days30) expiring_leases LeaseContract.query.filter( LeaseContract.end_date today, LeaseContract.end_date thirty_days_later, LeaseContract.status active ).count() pending_repairs RepairRecord.query.filter_by(stagepending).count() current_month today.strftime(%Y-%m) total_depreciation db.session.query( db.func.sum(DepreciationRecord.depreciation_amount) ).filter(DepreciationRecord.period current_month).scalar() or 0 return success({ expiring_leases: expiring_leases, pending_repairs: pending_repairs, total_depreciation: float(total_depreciation) })前端拿到这三个数字用 Element UI 的统计卡片展示即可。这种看板功能实现成本极低但给用户的体验提升非常明显——不用自己翻列表去数快到期的是哪几份合同。6. 联调阶段最容易踩的坑跨域、鉴权、文件上传6.1 跨域问题Flask-CORS 的配置与踩坑前后端分离开发时Vue 跑在 8080Flask 跑在 5000浏览器会拦截跨域请求。最常见的解决方案是安装flask-corsfrom flask_cors import CORS CORS(app, resources{r/api/*: {origins: http://localhost:8080}})这个配置看似简单但有一个容易忽略的问题如果前端请求带了自定义 Header比如 Authorization后端必须在 CORS 配置里允许该 HeaderCORS(app, resources{r/api/*: { origins: http://localhost:8080, allow_headers: [Content-Type, Authorization], methods: [GET, POST, PUT, DELETE, OPTIONS] }})否则前端会报CORS header Access-Control-Allow-Headers is missing。这个问题排查起来非常迷惑因为明明配置了 CORS 却还是跨域报错。不过我在开发阶段一般不用 Flask-CORS而是用 Vue CLI 的 devServer 代理。配置vue.config.js如下module.exports { devServer: { proxy: { /api: { target: http://localhost:5000, changeOrigin: true } } } }这样前端请求/api/xxx时开发服务器会把请求转发到 Flask浏览器看到的是同源请求完全绕过了 CORS。这个方案在生产环境用 Nginx 反向代理同样适用所以代码里不需要为 CORS 做任何特殊处理。6.2 JWT 鉴权与接口权限控制管理系统的接口不能裸奔需要登录鉴权。我用的方案是 JWTJSON Web Token流程是用户输入用户名密码后端验证后签发 Token。前端把 Token 存到 localStorage。后端提供一个token_required装饰器校验请求头里的 Authorization。Flask 里实现 JWT可以直接用pyjwt库不需要引入flask-jwt-extended这种重框架虽然它确实更方便。import jwt from functools import wraps from flask import request, jsonify SECRET_KEY your-secret-key def token_required(f): wraps(f) def decorated(*args, **kwargs): token request.headers.get(Authorization) if not token: return jsonify({code: 401, message: 未提供认证令牌}), 401 try: if token.startswith(Bearer ): token token[7:] payload jwt.decode(token, SECRET_KEY, algorithms[HS256]) request.user_id payload[user_id] except jwt.ExpiredSignatureError: return jsonify({code: 401, message: 登录已过期}), 401 except jwt.InvalidTokenError: return jsonify({code: 401, message: 无效的认证令牌}), 401 return f(*args, **kwargs) return decorated这里有一个安全细节JWT 的 SECRET_KEY 一定不要硬编码在代码里应该从环境变量或配置文件中读取。项目被 Git 托管时如果你不小心把密钥提交上去任何人都可以伪造 Token 访问你的接口。权限控制层面我的建议是不要过度设计。先想清楚系统里有哪几类角色比如资产管理员可以增删改查资产、财务可以执行折旧计算和查看报表、普通员工只能查看资产信息并发起报修。在token_required的基础上再加一个role_required装饰器传入允许的角色列表拦截没有权限的请求即可。6.3 资产图片上传与静态文件服务资产卡片经常需要上传资产照片这里会遇到两个问题一是 Flask 默认的静态文件夹是static/前端上传文件时必须让后端知道文件存放路径。我习惯把上传目录配置在 config 里class Config: UPLOAD_FOLDER os.path.join(os.path.dirname(__file__), uploads) ALLOWED_EXTENSIONS {png, jpg, jpeg, gif, pdf}二是 Flask 在开发环境处理文件上传的方式app.route(/api/upload, methods[POST]) token_required def upload_file(): file request.files.get(file) if not file: return error(未收到文件) if not allowed_file(file.filename): return error(文件类型不支持) filename f{uuid.uuid4().hex}_{secure_filename(file.filename)} file.save(os.path.join(current_app.config[UPLOAD_FOLDER], filename)) return success({url: f/uploads/{filename}})注意secure_filename这个函数非常重要它可以过滤掉文件名中的路径分隔符和特殊字符防止路径穿越攻击。生产环境下/uploads目录不应该由 Flask 直接服务而是交给 Nginx 做静态文件映射。配置如下location /uploads/ { alias /path/to/asset_management/uploads/; }7. 测试与部署保证系统真正能交给用户用7.1 核心业务逻辑的单元测试这种管理系统虽然没有算法复杂度但折旧计算这种纯逻辑函数很适合做单元测试。我用 pytest 写了几个核心测试用例def test_straight_line_depreciation(): asset Asset( original_value12000, residual_rate0.05, useful_life60 ) amount calculate_straight_line_depreciation(asset, 2025-06) # 12000 * 0.95 / 60 190.0 assert amount 190.0 def test_last_period_no_negative_net_value(): # 模拟折旧到只剩 100 元净值的场景 # 验证修正逻辑不会产生负数净值 pass测试的价值不在于现在而在于未来。当你后来改了折旧逻辑比如新增了一种折旧方法跑一遍测试就能确认原有业务没被破坏。7.2 用 Gunicorn Nginx 部署Flask 自带的开发服务器app.run()绝对不能用于生产环境它既慢又不安全。我用的方案是 Gunicorn 做 WSGI 服务器Nginx 做反向代理。Gunicorn 启动命令gunicorn -w 4 -b 127.0.0.1:5000 run:app-w 4表示启动 4 个工作进程。对于这个规模的管理系统4 个 worker 足够支撑几十人同时使用。Nginx 配置server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { root /path/to/frontend/dist; try_files $uri $uri/ /index.html; } }这里try_files ... /index.html是 Vue Router 使用 history 模式时的标配保证前端路由刷新时不会 404。7.3 交付后的维护建议固定资产系统这类项目交付不是结束而是维护的开始。根据我的经验有几点值得注意备份策略SQLite 或 MySQL 的备份要定期自动化固定资产数据的丢失对企业来说是重大事故。数据导入用户大概率有存量资产数据需要批量导入一定要做一个 Excel 导入功能否则手动录入几百条资产记录会让他们直接放弃使用。审计日志记录谁在什么时候修改了哪条资产记录。很多企业有审计需求这个功能在需求阶段可能没提但上线后大概率会补。我个人在实际项目里体会最深的一点是这类系统真正的技术难点从来不是某个框架的用法而是如何把你对业务的理解准确翻译成数据模型和状态流转规则。Flask、Vue 这些工具熟练之后都只是手段。所以如果你正准备做类似的项目建议花 30% 的时间搞懂业务30% 的时间设计数据库剩下的时间写代码你会发现整个过程顺畅很多。