
做这套新能源汽车S店保养服务管理系统起因是朋友在门店里受够了“纸质工单微信群预约”的模式。新能源车不像燃油车保养项目那么固定电池状态、高压部件检查、冷却液更换都要记录单靠表格很容易漏。于是我用 Python 3 和 Django 把预约、工单、配件库存和保养提醒串成了一套系统同时用 Flask 快速写了几个原型页来验证流程。这篇文章就把整个项目的设计思路、核心实现、以及我踩过的坑完整记录下来给想用 Python 快速搭建管理系统的朋友当参考。这套系统最终叫“S店保养服务管理”核心不是炫技而是把门店日常最烦的三件事解决掉微信里反复确认预约时间、配件出库后不知道库存还剩多少、客户什么时候该保养没人主动提醒。前端我用的是 Django 模板加原生 Bootstrap没有引入复杂前端框架后端用 Django ORM 操作 MySQL本地调试用 SQLiteFlask 只出现在早期原型和几个内部工具里。接下来我会从业务拆解、技术选型、数据模型、核心流程到问题排查一步步讲清楚。1. 业务分析与系统设计思路1.1 新能源车保养业务和燃油车有什么不同很多现成的4S店系统是为燃油车设计的保养项目主要围绕机油、机滤、火花塞但新能源车根本不用机油保养重点变成了空调滤芯、制动液、减速器齿轮油、冷却液以及高压系统绝缘检查。更关键的是新能源车需要记录电池健康状态和充电习惯这些字段在通用系统里根本没有。如果把燃油车那套工单逻辑硬套过来车间师傅还得把电池信息写在备注里后期统计非常痛苦。所以我在做需求调研的时候专门跟服务顾问和技师聊了两次。他们最关注的是客户车辆信息能不能一次录入后长期复用保养项目能不能按车型快速勾选工单状态能不能清楚显示当前在谁手里。库管关注的是配件出库后能否自动扣库存最好还能看到哪些低值易耗品快用完了。管理层则更在意到期未保养的客户名单方便打电话邀约回店。把这些需求汇总起来系统设计就有了明确目标。1.2 功能模块拆分与优先级排序我不建议一上来就做一堆高大上的模块真实门店最需要的其实是几个高频操作入口。第一版我先拆成六个模块客户与车辆档案、预约管理、保养工单、配件库存、提醒管理、系统用户与权限。客户档案负责记录姓名、手机号、车型、车架号、电池类型等信息预约管理负责登记客户到店时间和预约项目工单是整个系统的核心记录技师施工过程、使用配件和工时费用配件库存负责出入库和库存扣减提醒管理根据保养周期自动生成待回访名单。按优先级排序的话工单和客户档案是地基必须最先做。预约和库存是第二批提醒可以放到第三版。我当时先用纸笔画了一遍“客户进店到离店”的全流程确认每一步需要录入什么数据再开始建表。没有这一步直接写 Django models 很容易出现字段缺漏后期迁移改表会非常痛苦。下面这个流程是最终版本客户电话或微信预约服务顾问在系统中查询车辆档案如果车从未入库先创建客户和车辆信息录入预约单选择保养项目和期望到店时间车辆到店后从预约单转生成工单技师接收技师检查并录入检查结果选择需要更换的配件申请领料库管出库并扣减库存服务顾问核算工时费与配件费工单状态变为“已完成”系统自动记录本次保养里程和时间1.3 数据模型设计的核心思想数据模型是整个项目最需要花心思的地方。我的原则是一个页面只处理核心数据不要试图一张表承载所有信息。客户、车辆、工单、配件要分表车辆和客户是一对多关系工单和车辆是多对一关系工单和配件是多对多关系但需要通过中间表记录使用数量和单价。用外键把这些表串起来既方便统计也能避免重复录入。为了后续维护方便所有主键都使用 Django 自增的 BigAutoField不做业务主键。像车牌号这种字段虽然唯一但可能换牌所以只加索引不加主键约束。状态字段用 IntegerField choices 而不是直接存汉字这样后面做状态筛选和条件判断更靠谱也避免手写错别字。第一版上线后我才发现把“施工中”写成“施工中”还是“施工进行中”Excel 能忍关系数据库不能忍所以枚举值必须代码里写死。2. 技术选型Python 生态下为什么最终选了 Django2.1 Flask 原型让我看清了需求很多人纠结 Django 和 Flask我的建议是别用节约时间的借口逃避真实对比。这个项目我一开始是用 Flask 快速做了预约登记页面和工单列表页加起来也就一百多行代码。Flask 的最大优势是简单直接一个路由一个函数模板用 Jinja2配合 Flask-SQLAlchemy 和 Jinja2 就能快速看到东西。但 Flask 的优势也是它的短板。当我开始加入用户登录、多角色权限、后台数据录入页面时Flask 需要自己拼 session 登录逻辑还要选 Flask-LoginAdmin 后台要装 Flask-Admin 并自己写一堆配置。Django 自带的后台只要把模型注册进去马上就有增删改查页面权限组也天然支持。这让我意识到如果一个项目要长期维护框架自带的“完整后勤”比“轻量灵活”更重要。2.2 Django 和 Flask 的选型对比对比维度Django 4.2Flask 2.3自带 Admin 后台有注册模型即用无需 Flask-Admin 扩展用户认证与权限内置 User、Group、Permission需 Flask-Login 自行集成ORM 与迁移自带 ORMmigration 管理成熟多用 SQLAlchemy需 Aalembic 搭配表单处理Forms/ModelForm 自动处理校验需手工处理 request.form学习曲线较多概念但系统化起步快后期整合麻烦适合场景中大型管理系统、内部工具小 API、原型验证、定制化服务说白了这个项目是典型的管理系统有大量表格页面和增删改查Django 的 ModelAdmin 能帮我省掉至少一周的样板代码。Flask 也不是不能做但同样的功能需要拼装五六个第三方库缝缝补补很累。我后面还是保留了 Flask 写的原型用来做几个不需要登录的小功能比如门店大屏轮播保养知识这样两边各司其职。2.3 为什么不考虑前后端分离团队里没有人专门写前端如果上 Vue 或 React接口文档、跨域、Token 刷新这些问题会直接拖垮进度。Django 模板加简洁的后端渲染打开网页就能录入数据非常符合门店收银台的场景。等以后要做小程序或者 App再单独部署一套 REST API用 Django REST Framework 也没问题第一版完全不用为了“未来可扩展”消耗当前精力。当然我也给 Flask 留了一个合理的定位系统每天凌晨要跑一个保养提醒脚本生成今日到期客户清单这个逻辑我用 Flask 写成一个独立的小工具每天由 cron 调用输出 Excel 发给服务顾问。但这跟主系统没有代码耦合只是数据源共用同一个 MySQL。这样既躲开了 Django 启动慢的问题又符合“轻量任务轻量做”的思路。3. 核心功能开发与实操记录3.1 开发环境准备与项目初始化我用的开发环境是 Windows 11 Python 3.10但建议有条件直接上 Linux文件路径坑会少很多。先创建虚拟环境再安装依赖python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install django4.2 mysqlclient2.2.0 pip install django-crispy-forms2.0mysqlclient 在 Windows 上编译容易失败可以直接装mysqlclient的预编译轮子或者退一步用 PyMySQL 并在settings.py里配置如下内容import pymysql pymysql.install_as_MySQLdb()接下来创建项目和 appdjango-admin startproject autosvc cd autosvc python manage.py startapp customers python manage.py startapp maintenance python manage.py startapp parts python manage.py startapp alerts记得在settings.py的INSTALLED_APPS里注册这些 app否则后面迁移表会缺失。环境搭建这一步没什么高深技术但经常有人因为忘记做虚拟环境把依赖装进全局 Python导致版本混乱这个习惯一定要从一开始就养成。3.2 数据模型实现车辆档案与工单怎么建立关系下面是我简化后的核心 models 代码保留了实际项目里最关键的字段。客户和车辆分开是因为一个客户可能名下有多辆车车也需要独立建档保养记录挂在工单上方便之后按里程查历史。from django.db import models from django.contrib.auth.models import User class Customer(models.Model): name models.CharField(verbose_name客户名称, max_length50) phone models.CharField(verbose_name手机号, max_length20, db_indexTrue) address models.CharField(verbose_name地址, max_length200, blankTrue) created_at models.DateTimeField(auto_now_addTrue) class Vehicle(models.Model): customer models.ForeignKey(Customer, on_deletemodels.PROTECT, verbose_name所属客户) plate_no models.CharField(verbose_name车牌号, max_length10, db_indexTrue) vin models.CharField(verbose_name车架号, max_length20, uniqueTrue) brand models.CharField(verbose_name品牌, max_length50) model_name models.CharField(verbose_name车型, max_length100) battery_type models.CharField(verbose_name电池类型, max_length20, choices[(LFP, 磷酸铁锂), (NCM, 三元锂)]) current_mileage models.IntegerField(verbose_name当前里程, default0) def __str__(self): return f{self.plate_no} {self.model_name} class Appointment(models.Model): vehicle models.ForeignKey(Vehicle, on_deletemodels.CASCADE, verbose_name车辆) service_date models.DateTimeField(verbose_name预约到店时间) service_items models.TextField(verbose_name预约项目, help_text逗号分隔) status models.IntegerField(choices[ (1, 待确认), (2, 已到店), (3, 已取消) ], default1) remark models.CharField(max_length500, blankTrue) class MaintenanceOrder(models.Model): order_no models.CharField(verbose_name工单号, max_length30, uniqueTrue) vehicle models.ForeignKey(Vehicle, on_deletemodels.PROTECT, verbose_name车辆) appointment models.ForeignKey(Appointment, nullTrue, blankTrue, on_deletemodels.SET_NULL, verbose_name关联预约) status models.IntegerField(choices[ (1, 待施工), (2, 施工中), (3, 待结算), (4, 已完成), (5, 已取消) ], default1) check_result models.TextField(verbose_name技师检查记录, blankTrue) total_cost models.DecimalField(max_digits10, decimal_places2, verbose_name总费用, default0) finished_at models.DateTimeField(verbose_name完工时间, nullTrue, blankTrue) class Part(models.Model): part_code models.CharField(verbose_name配件编码, max_length50, uniqueTrue) name models.CharField(verbose_name配件名称, max_length100) stock models.IntegerField(verbose_name库存数量, default0) low_stock_threshold models.IntegerField(verbose_name低库存预警值, default5) price models.DecimalField(max_digits8, decimal_places2, verbose_name销售单价) class OrderItem(models.Model): order models.ForeignKey(MaintenanceOrder, on_deletemodels.CASCADE, verbose_name工单) part models.ForeignKey(Part, on_deletemodels.PROTECT, verbose_name配件) quantity models.IntegerField(verbose_name数量, default1) price models.DecimalField(max_digits8, decimal_places2, verbose_name成交单价)用on_deletemodels.PROTECT而不是CASCADE是为了防止误删除一个客户时把他的历史工单全部连带删掉。门店数据是资产的真实记录宁可禁止删除也不能静默丢数据。工单号我用了ODO-YYYYMMDD-XXXX的格式创建时通过随机数加日期生成不用数据库自增这样工单打印出来也好看。3.3 登录与角色权限Django 自带 Auth 就用起来Django 的User模型加上Group就够了我会为门店创建三个组服务顾问、车间技师、库管员。服务顾问能创建预约和结算技师能填写检查内容和更新工单状态库管员只能操作配件模块。视图层面用装饰器控制入口from django.contrib.auth.decorators import login_required, permission_required from django.shortcuts import render, redirect login_required def dashboard(request): return render(request, dashboard.html) login_required permission_required(maintenance.change_maintenanceorder, raise_exceptionTrue) def update_order_status(request, order_id): ...权限控制最重要的是要早规划。我一开始没分权限所有登录用户都能改工单后来技师误点了“已完成”导致服务顾问那边没法结算才补上权限分组。Django 后台里添加用户并分配到组比在代码里写死角色判断要省事得多所以不要在模板里到处判断request.user.username而是统一使用has_perm或模板中的perms变量。3.4 预约登记与工单生成业务流程闭环核心逻辑是从预约单生成工单同时要防止重复生成。下面是一个经过裁剪的视图逻辑展示了事务和状态校验怎么配合from django.db import transaction from django.utils import timezone login_required transaction.atomic def create_order_from_appointment(request, appointment_id): appointment Appointment.objects.select_for_update().get(idappointment_id) if appointment.status ! 1: messages.error(request, 该预约状态不可转工单) return redirect(appointment_list) if MaintenanceOrder.objects.filter(appointmentappointment, status__in[1, 2, 3]).exists(): messages.error(request, 该预约已存在进行中的工单) return redirect(appointment_list) order_no generate_order_no() order MaintenanceOrder.objects.create( order_noorder_no, vehicleappointment.vehicle, appointmentappointment, status1, ) appointment.status 2 appointment.save() messages.success(request, f工单 {order_no} 已创建) return redirect(order_detail, order_idorder.id)这里必须使用select_for_update()加行锁否则两个员工同时点击“转工单”会出现同一预约生成两个工单的情况。transaction.atomic保证工单创建和预约状态更新要么都成功要么都失败。这个场景在真实门店很常见尤其是早上开早会后集中录单的时候。3.5 保养到期提醒用 Django Command Cron 替代 Celery像保养提醒这种功能不需要上 Celery定时任务用系统的 cron 就够了。我在项目里建了一个alertsapp写了一个 Django commandpython manage.py send_maintenance_reminders命令逻辑是这样的查询所有车辆找到该车辆最近一条“已完成”状态的工单里的finished_at和保养后里程如果超过规定周期比如 12 个月或 10000 公里生成一条提醒记录并将客户手机号导出到 Excel。真正执行时是每天早会前跑一次。from django.core.management.base import BaseCommand from alerts.models import Reminder from customers.models import Vehicle class Command(BaseCommand): help 生成保养到期提醒 def handle(self, *args, **options): for vehicle in Vehicle.objects.select_related(customer).all(): last_order vehicle.maintenanceorder_set.filter( status4).order_by(-finished_at).first() if not last_order: continue days_elapsed (timezone.now() - last_order.finished_at).days if days_elapsed 330: Reminder.objects.get_or_create( vehiclevehicle, remind_typeregular, defaults{due_days: 365 - days_elapsed} ) self.stdout.write(f已提醒: {vehicle.customer.name})这里用get_or_create防止重复提醒。如果你希望每天只给同一辆车提醒一次就在提醒表上加唯一约束。实际上门店更看重的是“一次性输出 Excel 给邀约专员”而不是在网页上弹一堆红点所以我的 command 还生成了一个reminders_today.xlsx文件放在 MEDIA 目录下服务顾问打开就能打电话。3.6 配件库存联动事务和锁要同时用保养工单里换的每个配件都要在生成工单时锁定库存。真正的业务是技师开单后库管员确认出库这时才扣库存而不是创建工单一瞬间就扣。我在 OrderItem 保存时写了一个方法transaction.atomic def confirm_stock_out(self): items self.orderitem_set.select_for_update().select_related(part) for item in items: part item.part if part.stock item.quantity: raise ValueError(f配件 {part.name} 库存不足) part.stock - item.quantity part.save(update_fields[stock])这里有个不容易发现的坑select_for_update只对查询涉及的行生效如果OrderItem表里同一条记录被同事同时修改了就需要在事务里重新查询并锁定而不仅仅是锁Part行。我当时的处理是直接锁Part行因为真正要保证不出错的是配件库存不能被扣成负数工单明细本身不可变所以锁Part就够了。update_fields可以避免覆盖掉其他字段的并发更新这个习惯很值得培养。3.7 如果非要换回 Flask核心差异点常常有朋友问我这套系统能不能用 Flask 改写能但你需要重新规划三件事。一是数据迁移Flask 默认没有 migration 工具需要自己继承 Flask-Migrate每个 model 变更都要手动生成迁移脚本二是登录和权限Flask-Login 提供了current_user但角色的权限明细还是要自己做一个装饰器三是后台管理Flask-Admin 远不如 Django Admin 成熟对于嵌套关系工单连接配件需要写更多自定义视图。简单对比一个 Flask 路由和视图from flask import Flask, request, render_template from flask_sqlalchemy import SQLAlchemy app Flask(__name__) db SQLAlchemy(app) app.route(/vehicles/int:vid) def vehicle_detail(vid): vehicle Vehicle.query.get(vid) orders MaintenanceOrder.query.filter_by(vehicle_idvid).all() return render_template(vehicle_detail.html, vehiclevehicle, ordersorders)看着和 Django 差不多但 Flask 的Vehicle.query.get只是查询Django 还能帮你自动生成后台、迁移文件、表单校验。如果你是给公司内部做一个十天就能上线的管理系统我依然推荐 DjangoFlask 更适合做小接口、小工具而不是全功能业务系统。4. 部署与常见问题排查实录4.1 静态文件加载不出来的排查步骤VSCode 里写 Django 项目时最容易遇到img标签加载不了static文件。很多人把图片放到了static/img/logo.png模板里直接写srcimg/logo.png结果浏览器报 404。Django 处理静态文件必须有{% load static %}然后使用{% static img/logo.png %}。如果模板里用的是原始路径必须保证路径以/static/开头。项目配置里先确认STATIC_URL /static/ STATICFILES_DIRS [BASE_DIR / static]开发时runserver可以自动服务静态文件但生产环境必须执行python manage.py collectstatic把静态文件收集到STATIC_ROOT目录否则 Web 服务器访问不到。我遇到过最奇怪的一次是本地能显示、服务器上图片全裂就是因为STATIC_ROOT和STATICFILES_DIRS配成了同一个目录collectstatic 把源文件清掉然后再写入文件变成空了。这个坑务必记一下。4.2 时间都是按本地时间存但提醒总差一天Django 默认USE_TZTrue数据库里存的是 UTC 时间而中国门店操作是东八区时间。如果在代码里用 Python 标准库datetime.now()获取时间再跟数据库里的 UTC 时间比较会差 8 小时轻则提醒日期差一天重则工单完工时间错乱。正确姿势是在settings.py里保留TIME_ZONE Asia/Shanghai USE_TZ True然后在所有需要获取当前时间的地方都用django.utils.timezone.now()或localtime()。如果发现自己写的代码全是datetime.now()尤其是涉及工单状态变更的地方赶紧改掉。我排查“昨天完工的工单今天看日期还是昨天”这个问题时就是被这里的时区规则坑了两天。4.3 配件库存被扣成负数怎么解决出现负数通常有两个原因一是出库逻辑没有放进事务两个操作同时读取库存为 5同时扣 6结果都写入 0 或 -1二是没有加锁。解决办法是前面提到的select_for_update并在扣减之前检查库存。如果业务量很大还可以给Part.stock字段加一个db_constraint和CheckConstraint强制数据库层面禁止负数class Meta: constraints [ models.CheckConstraint( checkmodels.Q(stock__gte0), namepart_stock_non_negative ) ]这样就算代码有漏洞数据库也会抛异常不会让脏数据落地。实战经验是数据库约束是最后一层防线永远不要依赖应用层代码检查“只要我不失误就行”。4.4 图片上传与附件路径在 Flask 和 Django 里的不同如果系统需要客户证件、事故照片或保养单据照片路径配置很容易出错。Django 里用MEDIA_ROOT和MEDIA_URL处理上传文件不能直接扔到static目录下因为static是给代码资源用的用户上传内容要单独隔离。在本地开发时可以临时在 url 配置中加from django.conf import settings from django.conf.urls.static import static urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)Flask 里则需要自己配置UPLOAD_FOLDER保存文件时最好按日期分子目录避免一个文件夹里文件过多。早期跑 Flask 项目时我把附件路径写成了相对路径结果在服务器上用 systemd 启动服务后路径找不到因为工作目录根本不是项目根目录后来改成基于app.root_path的绝对路径就好了。经验就是所有文件存储路径不要依赖当前工作目录要么用绝对路径要么用配置项。4.5 从本地 VSCode 调试到服务器部署的避坑点本地用python manage.py runserver非常舒服但部署到服务器就不能继续用开发服务器。我用的是 nginx Gunicorn 常见组合部署前必须检查几个点。第一ALLOWED_HOSTS要包含域名或服务器 IP否则直接返回 400第二DEBUG必须改成 False否则静态文件服务方式和异常页面都会暴露路径第三数据库要从 SQLite 切到 MySQL迁移前先备份数据转移时常见编码和时区问题也多建议在 MySQL 中统一使用 utf8mb4。如果你坚持用 Flask 部署也有两个经典问题app.run(host0.0.0.0, debugTrue)只适合内网调试生产用 Gunicorn 时一定要确保if __name__ __main__里才调用app.run上传的附件目录要手动创建并检查权限否则PermissionError会让你怀疑人生。我做完这套系统最大的体会是管理系统开发的难点从来不是增删改查怎么写而是业务流程里那些“边界条件”一个预约不能被转两次工单、库存不能扣成负数、提醒不能同一天重复发。Django 把这些工具都提供了事务、锁、迁移、Admin 都有关键是建表之前先把业务想清楚。最后再分享一个小技巧开发阶段把 Django Admin 打开让门店同事自己先录一遍真实数据你会发现很多你没想到的业务规则早暴露早改比上线后再返工强太多。