外卖数据库管理系统
环境 MySQL Community Server 8.0.25 、Python 3.8、Tkinter
需求分析
本程序模拟一个外卖平台系统,该系统由三端构成即用户+商家+配送员。
不同的用户(三类)有不同的ID,姓名可重名。每个用户可以下多个订单,每个订单对应一个配送员,一个订单内可包含多个同一家店铺的商品,每个商家可以同时处理来自多个用户的多个订单。一个配送员可以同时派送多个订单,订单对应的配送员由外卖平台统一分配。
三类用户均可以以自己的账号密码登录对应的系统,可以注册新账号
实现的功能:
- 用户:登录/注册功能,修改用户信息功能,充值账户余额功能,获取正在营业的店铺菜单功能,点外卖提交订单功能,查询订单功能。
- 商家:登录/注册功能,一键上/下班功能 ,修改商家信息功能,营业额提现功能,商品管理模块包括:查询商品详情、新建商品、修改原有商品、删除商品功能。接单窗口显示已接单未出餐商品,标记餐品为我已出餐功能,查询最近已出餐商品功能。
- 配送员:登录/注册功能,一键上/下班功能 ,修改个人信息功能,工资提现功能,工作窗口显示派送给自己的所有订单,修改订单状态为已完成(点击我已送达)功能,查看最近派送完成的订单功能。
数据库设计
逻辑结构设计
- 商家:店名,商家号,登录密码,地址,电话,营业额,状态,
- 商品:编号,商品名,价格,商家号,库存
- 骑手:编号,登录密码,姓名,性别,电话,工资,状态,
- 顾客:ID,登录密码,姓名,性别,地址, 电话,余额。
- 订单:订单号,骑手号,顾客ID,状态,备注,配送费,金额,发起时间
- 订单详情:订单号,商品号,数量
关系属性
- Store(Sno,Spass,Sname,Saddr,Stel,Smoney,Sstate)
- Goods(Gno,Sno,Gname,Gprice,Gstock)
- Customer(Cno,Cpass,Cname,Csex,Caddr,Ctel,Cmoney)
- Deliverer(Dno,Dpass,Dname,Dsex,Dtel,Dmoney,Dstate)
- Order(Ono,Dno,Cno,Ostate,Otip,ODelfee,Omoney,Obtime)
- Purchase(Ono,Gno,Pamount)
E-R图
数据表
商店表(store)
- 主码:Sno
- 自定义完整性:
CHECK (Sstate IN (‘工作’,‘休息’)),
CHECK (Smoney >= 0)
商品表(goods)
- 主码:Gno
- 外码:FOREIGN KEY (Sno) REFERENCES Store(Sno)
顾客表(Customer)
- 主码:Cno
- 自定义完整性:
CHECK (Csex IN (‘M’,‘F’)),
CHECK (Cmoney >= 0)
派送员表(deliverer)
- 主码:Dno
- 自定义完整性:
CHECK (Dsex IN (‘M’,‘F’)),
CHECK (Dstate IN (‘工作’,‘休息’)),
CHECK (Dmoney >= 0)
订单表(orderr)
- 主码:Ono
- 外码:
FOREIGN KEY (Dno) REFERENCES Deliverer(Dno),
FOREIGN KEY (Cno) REFERENCES Customer(Cno) ,
FOREIGN KEY (Sno) REFERENCES Store(Sno) , - 自定义完整性:
CHECK (Omoney >= 0),
CHECK (ODelfee >= 0),
CHECK (Ostate IN(‘正在出餐’,‘正在配送’,‘订单完成’))
订单详情表(purchase)
- 主码:PRIMARY KEY (Ono, Gno ),
- 外码:FOREIGN KEY (Ono) REFERENCES Orderr(Ono) ,
FOREIGN KEY (Gno) REFERENCES Goods(Gno) ,
视图
用户点单视图
用户点单时可以看见所有商家的所有菜品。用户需要看到的是商店名而不是商店编号,商品名而不是商品编号。
CREATE VIEW view_Cus_buy AS
SELECT Sname '店名',Gname '商品',Gprice '价格'
FROM goods,store
WHERE goods.Sno = store.Sno
AND store.Sstate = '工作'
ORDER BY store.Sno ;
用户订单视图
用户可以看到订单的信息。
CREATE VIEW view_Cus_look AS SELECT
orderr.Ono,
store.Sname,
orderr.Omoney,
orderr.Ostate,
orderr.Obtime,
orderr.Cno
FROM orderr,store
WHERE store.Sno = Orderr.Sno
ORDER BY orderr.Obtime DESC ;
密码视图
将三类用户的密码整合在一起,并根据来源添加类型字段。方便验证密码。
CREATE VIEW Password ID,'密码','类型'AS
SELECT Cno ,Cpass ,'用户'
FROM Customer
UNION
SELECT Sno,Spass,'商家'
FROM Store
UNION
SELECT Dno,Dpass,'配送员'
FROM Deliverer ;
配送员视图
骑手无法查看订单所有信息,需要快速查看地址电话等,故需要单独设计视图。
CREATE VIEW view_del_unfinish AS
SELECT store.Sname,customer.Caddress,customer.Ctel,orderr.ODelfee,orderr.Ono
FROM store,customer,orderr
WHERE orderr.Ostate = '正在配送'
AND orderr.Sno=store.Sno
AND orderr.Cno=customer.Cno
ORDER BY orderr.Obtime DESC ;
商家端订单视图
商家看到的待出餐的订单信息。
CREATE VIEW view_store_unfinish AS
SELECT orderr.Ono,customer.Cname,customer.Ctel,orderr.Omoney
FROM store,customer,orderr
WHERE orderr.Ostate = '正在出餐'
AND orderr.Sno=store.Sno
AND orderr.Cno=customer.Cno
ORDER BY orderr.Obtime DESC ;
存储过程
利用存储过程实现对金融的操作(扣款/提现/充值)
顾客的充值与扣费函数+配送员获得工作与提现函数+商家获得订单利润与提现函数。
使用存储过程的好处:可以使计算过程在服务器完成,减轻本地负担,对敏感信息的操作与客户端分离,增强安全性。
DELIMITER $
CREATE PROCEDURE alter_del_money(IN user_id VARCHAR(20),IN amount INT)
BEGIN
update deliverer set Dmoney=amount WHERE Dno=user_id;
END $
DELIMITER ;
触发器
插入具体商品(Purchase表)时更新订单(Orderr表)信息
这里在插入一个商品后自动在对应的订单增加一个数量(对应配送费),修改对应的总金额。
使用触发器的好处:自动完成修改,防止遗漏,且操作在服务器,与客户端分离。
BEGIN
DECLARE a INT;
DECLARE b INT;
DECLARE c INT;
SET a = ( SELECT orderr.ODelfee FROM orderr WHERE orderr.Ono = new.Ono );
SET b = ( SELECT orderr.Omoney FROM orderr WHERE orderr.Ono = new.Ono );
SET c = ( SELECT goods.Gprice FROM goods WHERE goods.Gno = new.Gno );
UPDATE orderr
SET orderr.ODelfee = a + 1,
orderr.Omoney = b + c
WHERE
orderr.Ono = new.Ono;
END
UI设计
主界面
三个程序在界面上大体相同,我把他们的主界面分为几个几个区域:1.选择列表区 2.订单信息区 3.功能区
- 选择列表区:显示部分主要是由标题加表格和勾选框以及滑动条组成,操作有全选/取消全选、提交操作、刷新列表。
- 订单信息区:这个区域是只读的,只有刷新列表一个功能。
- 功能区:这个区域实时的显示用户信息并且有若干功能按钮,点击相应的按钮就进入相应的功能模块。
主界面是用tkinter.TK()
创建出来的,起到 ‘根界面’ 的作用即其它所有界面都是基于他来做的。创建主界面之后要用mainloop
将其显示出来。由于实际操作中需要先登录再进入主界面所以先把主界面用.withdrew()
方法隐藏起来,成功登录之后再用main_window.deiconify()
将其显示出来。
登录界面
打开程序最先跳出的就是登陆界面,在该界面输入用户密码后点击登录,如果验证正确则进入主界面,如果验证失败则跳出警告框提示,用户名密码信息均来自数据库。如果账户类型和程序不匹配也无法进入程序。
点击注册按钮会跳出注册界面,输入信息后点击确认注册,则会在对应的用户类型的数据表中插入一条记录,注册成功后就可以用该账户登录系统。
子窗口
点击一些功能按钮(如注册、修改信息、商品管理等)时会跳出一个子窗口在主界面上层以完成相应的模块的功能。
这里是用tk.Toplevel()
实现的,比如
window_sign_up = tk.Toplevel(loggin_window)
就在loggin_window界面的基础上创建了window_sign_up子界面。tk.Toplevel()和tk.TK()的功能几乎完全一样,不同的是tk.TK()是根界面不建立在其它界面之上。
这里采用模块化的设计方式,即一个子界面的所有内容都包装在一个函数(Python允许函数嵌套)或者一个类当中。
我这里大部分都是用函数嵌套来完成的,首先在函数内部定义子功能对应的子函数,然后设计对应子界面上的控件,并在控件的响应函数中调用上述定义的子函数。
消息窗口
messagebox
用于弹出消息窗口,通常用于显示提示消息,例如登录成功、登录失败
tk.messagebox.showerror(message='密码错误')
tk.messagebox.showinfo(title='welcome',message='登陆成功')
以上是两类不同的messagebox,一个用来显示常规信息,一个用来提示错误信息。
每个消息窗口都可以用title=‘’来指定信息窗口的标签,用message=‘’来指定消息窗口的内容。
标签
label
一般用来显示静态的文本或者图像,比如
tk.Label(main_window, text='用户名:').place(x=left_index, y=15)
tk.Label(main_window, text='余 额:').place(x=left_index, y=55)
tk.Label(main_window, text='已选:').place(x=40, y=480)
tk.Label(main_window, text='最近购买的订单:').place(x=440, y=180)
表示在main_window,这个界面上设置标签,放置位置在(x,y)处,内容是text。
label
还可以用来显示动态的内容,根据程序的进行不断改变内容。
total_wallet_show = tk.StringVar() # 用于展示余额
tk.Label(main_window, textvariable=total_wallet_show).place(x=left_index + 40, y=55)
total_wallet_show.set(rest_money)
以上建立了一个标签,显示内容为total_wallet_show
,其是tk.StringVar()
类型的变量,然后在程序需要刷新这个内容的时候调用total_wallet_show.set()
将其标签上的内容刷新。
按钮
按钮button
用来实现用户对某个功能的选择,button是可以点击的,点击相应的button就会跳转到对应的处理函数。
bt_charge = tk.Button(main_window, text='充值余额', height=1, width=16, command=charge_money)
bt_charge.place(x=left_index - 140, y=50)
bt_user_info = tk.Button(main_window, text='查询修改个人信息', height=1, width=16, command=alter_info)
bt_user_info.place(x=left_index - 140, y=10)
比如这里设置了bt_charge和bt_user_info两个按钮,按钮放置在main_window的(x,y)位置,按钮上的显示信息是test,按钮大小是height X width,点击按钮会跳转到command指定的函数中去。
列表
本程序中的用户点单列表、订单列表、商品管理里面的商品详情等都是用Treeview
来做的,比如:
columns = ['订单号', '商家名', '金额', '状态', '日期']
width_ord = [45, 70, 50, 70, 110]
order_list_table = Treeview( master=main_window, height=10, columns=columns, show='headings')
order_list_table.place(x=440, y=200)
for i in columns:
order_list_table.heading(i, text=i) # 定义表头
t = 0
for i in columns:
order_list_table.column(i, width=width_ord[t], minwidth=40, anchor=S, ) # 定义列
t += 1
这里定义了一个Tree view表格order_list_table
表格共五列,分别对应columns中的元素。width_ord
的每一项分别是每列的宽度,其父容器是main_window,高度是10行,展示方式是’headings’即展示首行标题
插入待展示的数据的方式:
for row in flu_list: # 格式:店名、商品、价格
i += 1
order_list_table.insert('', 'end', values=row)
这里的flu_list
是一个从数据库中获取的二位列表
通过这种方式就把数据库中的数据展示在表格中了。
勾选框
用户点单,商家和配送员完成订单都需要勾选列表中的内容,这里利用 check button
实现,在treeview的每一行前面都设置一个checkbutton,由于treeview和checkbutton都是有编号的,所以可以将每一个勾选框的勾选状态和列表元素的选择联系起来。
ck_button['command'] = lambda item=tv_item: self.select_button(item)
def select_button(self, item):
a = eval("0x" + str(item[1:])) % len(cur_menu)
if a in order_list:
order_list.remove(a)
else:
order_list.add(a)
这里的order_list 集合内存的就是勾选的行对应在菜单内的编号,即勾选了的行的编号都在order_list内。
数据库相关算法设计
连接数据库
这里使用的是pymysql连接到本地的mysql数据库。
连接之后利用cursor方法创建普通的游标对象。
import pymysql
# 连接数据库
db = pymysql.connect(host='127.0.0.1', user="root", passwd="cc000822", db="restaurant")
# 使用cursor()方法获取操作游标
cursor = db.cursor()
从数据库获取数据(查)
这里我自己简单的封装了一个函数,用来做SELECT查询操作。该函数有两个参数,第一个是必选的参数用来指定选择的范围,第二个参数是可选参数用来指定选择条件等。返回值有两个列表类型的量,分别是结果的标题和结果本身(结果是二维列表),内部设有错误捕获语句,执行出错时会打印出出错的SQL语句,方便调试。
def select(table, par=""):
# SQL 查询语句
data_dict = []
results = []
sql = "SELECT * FROM %s %s;" % \
(table, par)
try:
# 执行SQL语句
cursor.execute(sql)
# 获取所有记录列表
results = cursor.fetchall()
data_dict = []
# 打印出标题
for field in cursor.description:
data_dict.append(field[0])
except:
print(sql + "_失败")
print("Error: unable to fetch data")
return data_dict, results
插入到数据库(增)
另一个封装的是插入INSERT函数,这个函数也很简单,两个参数分别是要插入的表(或者表中具体的列)和要插入的数据(VALUES后面的内容包括括号)。该函数没有返回值,若出错则会在错误捕获语句输出该语句,并回滚。
def Insert(table, par):
sql = "INSERT INTO %s VALUES %s;" % \
(table, par)
try:
# 执行SQL语句
cursor.execute(sql)
db.commit()
print(sql + "成功")
except:
db.rollback()
print(sql + "_失败")
print("Error: file to insert")
修改数据(改)
修改数据功能在本程序中没有统一的格式,故没有封装成函数的形式。这里就拿修改商家个人信息举例(其余的大同小异)。
由于可修改的信息不止一个,每个也都可以选择是否修改(若要修改就在相应位置填值反之不填),而UPDATE语句的SET之间要用逗号隔开且第一个之前没有逗号。
所以要做两个判断 :
- 判断每个属性是否需要被修改,需要的话就在SQL语句加上相应的部分
- 修改当前属性之前是否有属性已经被修改过(当前是否是第一个被修改的属性),若前面都没有被修改过即当前是第一个被修改的属性则前面不需要加上逗号否则需要加上逗号。
# 将新信息提交到数据库
changed = 0
sql_alt = 'UPDATE store SET '
if altered_pwd != "":
sql_alt += " Spass=\'" + altered_pwd + "\'"
changed = 1
if altered_addr != "":
if changed == 1:
sql_alt += ','
sql_alt += " Saddr=\'" + altered_addr + "\'"
changed = 1
if altered_tel != "":
if changed == 1:
sql_alt += ','
sql_alt += " Stel=\'" + altered_tel + "\'"
changed = 1
if altered_name != "":
if changed == 1:
sql_alt += ','
sql_alt += " Sname=\'" + altered_name + "\'"
sql_alt += "WHERE Sno=\'" + user_id + "\';"
try: # 执行SQL语句
cursor.execute(sql_alt)
db.commit()
user_pwd = identify_pwd # 修改本地缓存的密码
tk.messagebox.showinfo(title="通知", message="修改成功!")
except:
db.rollback()
print(sql_alt + "_失败")
其它算法设计
涉及金融的修改
涉及到金融的修改全部不直接用UPDATE的方式,而是调用在服务器端设置的三个存储过程
`alter_cus_money`、`alter_del_money`、`alter_store_money`。
三个存储过程的功能完全相同,不同之处在于面向的对象以及被调用的场景。
alter_cus_money
在用户提交订单并做相应的检查(余额是否足够等)之后扣除本次用户的花费,在用户充值余额并成功验证密码之后增加充值的金额。
alter_store_money
在用户提交订单完成之后,商家界面上的列表出现对应的订单,在商家对订单点击我已出餐之后,商家营业额会增加该订单对应的金额。在商家提现并验证完密码做完安全性检查之后营业额会减去相应的提现金额。
alter_del_money
商家点击我已出餐之后,配送员界面上未配送订单会出现,配送员送完餐后点击我已送达(完成订单)之后配送员的工资会增加该单对应的配送费会。在配送员点击提现按钮,提现并验证完密码做完安全性检查之后营业额会减去相应的提现金额。
顾客提交订单功能
用户提交订单的时候,程序只知道两个消息 一是提交时刻对应的菜单 二是订单内所有的商品在菜单中的编号,这两个商品分别存在以下全局变量中
order_list = set() # 当前购买的
cur_menu = [] # 当前的菜单
由于每个订单内都有可能包含来自多个商家的多个商品。需要处理的问题是如何正确更新orderr和purchase两个表,如何分配配送员。
解决方法:
用户下一次订单,系统识别出来自哪些商家,并将其按照商家拆分成不同的订单,每个订单对应一个商家以及一个配送员,每个订单只对应来自一个商家的商品。
实际操作过程中只需要遍历整个列表,并记录当前下的单对应的商店集合,如果当前商品来自的商店不在该集合内则加入进去并在orderr中插入一条记录。然后对于每个商品,其对应的店铺无论是否在集合内(不在的刚才已经新建了,所以最终结果是都在),均要在purchase表中插入一条记录。
在插入orderr表的时候需要为其分配一个订单号和一个配送员,所以需要事先获取订单表已经存在的最大编号,将新订单号分配为这个最大编号+1,还要获取全部在上班的配送员列表,在列表中随机挑选一个配送员将其信息插入对应的订单中。
订单插入orderrd的时候还没有订单对应的商品,所以订单金额和配送费都是初始值。每在purchase表中插入一条记录时,要在对应的orderr记录配送费加一以及总金额加上商品价格。这里是用触发器来实现的,每次在purchase插入新内容之后自动完成这一任务,不再需要程序干预。
当order_list
遍历结束后订单的提交也就结束了。
商家和配送员完成订单功能
在用户提交一个订单之后,该订单的状态为正在出餐,商家可以在未出餐列表中看到该订单,商家准备完成后,选择相应的订单点击我已出餐,订单状态变为正在配送,配送员在待配送列表中可以看到该订单,配送员配送完成后点击我已送达,订单状态变为订单完成。全程都可以在订单栏看到订单的状态。
商家和配送员的完成订单功能类似,因为程序沿用了客户端的部分代码,命名规则也直接沿用了过来, cur_menu,order_list = set()
这两个变量分别代表当前待处理的订单列表,和已选择的订单编号。所以提交处理订单时只需要遍历order_list 列表并找到cur_menu对应编号的订单,修改状态即可。
一键修改工作状态
商家和配送员都有切换工作状态的功能,按下相应按钮后会在工作/休息之间切换。
商家的状态决定了其商品是否能在用户的菜单上显示,用户的菜单只会显示当前在工作状态的商店的菜品,所以处于休息状态的商家无法接到新订单。配送员类似,系统分配新订单给派送员的时候只会分配给在工作状态的配送员。
实现方法:
在程序中创建一键切换工作状态后,将其command设置为change_work_state()
函数,在此函数内会先读取工作状态(全局变量,每次刷新控件时都会更新一次),然后再根据当前状态生成UPDATE语句提交到数据库执行,并将执行的结果反馈在消息框中。
商品管理模块
这个模块有查询商品详情、新建商品、修改原有商品、删除商品四个功能。
点击商品管理后进入该模块,左侧是商品详情区域,展示所有本店铺的商品信息。右上方是输入区,输入对应功能需要的信息,右下方是功能选择区域,点击其中的按钮会读取上方对应的信息并做对应的操作。
新建商品功能需要录入全部的信息并且保证商品ID不重复,价格和库存不为负值。
修改商品必须填入ID,其它选择要修改的填写即可。
删除商品只需要填入商品ID即可。