프로젝트 개요
- 스터디 카페를 운영하는 지인이 있는데, 스터디 카페에 어떤 손님들이 방문하는지에 대해서 확인이 잘 안된다는 이야기를 들었다.
- 키오스크에서 고객 결제 데이터를 확인할 수 있지만, 아래와 같이 각 결제내역별로 하나의 행으로 이루어져 있어서, 고객별, 일자별, 월별로 정리해서 한눈에 매출 추이나 경향을 파악하기 힘들다.
- 데이터를 가공, 수집하여 대시보드에서 신속하게 원하는 정보를 선별해서 볼 수 있도록 프로젝트를 진행해 보았다.
- 주요 개발 프레임워크 : 파이썬, Flask, Pandas, Dash, Skeleton CSS,
주요 코드
from typing import DefaultDict
import dash
import dash_core_components as dcc
from dash.dependencies import Input, Output, State
import dash_html_components as html
import numpy as np
import plotly.graph_objs as go
import dash_bootstrap_components as dbc
import pandas as pd
from clean import clean_file, clean_file_crypt, get_MVPs, Cafe_user, empty_fig, user_stack_chart, df_property
import dash_table
from datetime import date, datetime
from dateutil.relativedelta import relativedelta
import plotly.express as px
import time
import copy
app = dash.Dash(
__name__, meta_tags=[{"name": "viewport", "content": "width=device-width"}],
)
app.title = "OOO 스터디카페 OO점"
## default setup
colors = {'background':"#111111",'text':'#7FDBFF'}
################# creating variables
emptyfig = empty_fig()
myfile = '/home/pi/dash_examples/studycafe/data/raw.txt'
# clean_df = clean_file(myfile)
clean_df = clean_file_crypt(myfile)
# this is for getting the filters
clean_property = df_property(clean_df)
clean_range = clean_property.monthdf.index
myfilters = {
"type1":clean_property.type1,
"type2":clean_property.type2,
"type3":clean_property.type3,
"type4":clean_property.type4,
"type5":clean_property.type5,
"user1":clean_property.user1,
"user2":clean_property.user2,
"user3":clean_property.user3,
}
df = get_MVPs(clean_file(myfile))
testnumber = '01020780662'
testuser = Cafe_user(clean_df,testnumber)
def getdate():
mydate = datetime.today().date()
return mydate
## helper functions
def clean_file_crypt(file):
# date 가져올 때 제대로 가져오기
date_cols = ['일자']
df = pd.read_csv(file,encoding='CP949',delimiter='\t',parse_dates=date_cols)
## 전화번호 뽑아내기
df['전화번호'] = df['사용자ID'].str.extract(r'(\d{11})')
df
## grouping하기 (유형)
#1 추가사용요금 -> 추가사용요금
#2 정액권 -> 정액권
#3 1인석 -> 시간권
#4 스터디룸, 예약권 -> 스터디룸
#5 기간권 -> 기간권
#6 사물함 -> 사물함
f1 = df['좌석타입'].str.contains('추가사용요금', na=False)
f2 = df['좌석타입'].str.contains('정액권', na=False)
f3 = df['좌석타입'].str.contains('1인석', na=False)
f4 = df['좌석타입'].str.contains('|'.join(['스터디룸','예약권']), na=False)
f5 = df['좌석타입'].str.contains('기간권', na=False)
f6 = df['좌석타입'].str.contains('사물함', na=False)
df.loc[f1,'유형'] = '추가사용요금'
df.loc[f2,'유형'] = '정액권'
df.loc[f3,'유형'] = '시간권'
df.loc[f4,'유형'] = '스터디룸'
df.loc[f5,'유형'] = '기간권'
df.loc[f6,'유형'] = '사물함'
# 오류나는 행 지워주기 (관리자 행)
df = df[~df['유형'].isna()]
# 금액은 숫자로 바꿔주기
df['금액'] = df['금액'].str.replace(',','').astype(int).fillna(0)
# 내가 필요한 행만 남기고 다 날리기
df = df[['일자','유형','전화번호','금액']]
# 최초결제일 열 추가를 위한 전처리, df2 임시
df2 = df.loc[df.groupby('전화번호')['일자'].idxmin()]
df2 = df2[['일자','전화번호']]
df2.columns = ['최초결제일','전화번호']
finaldf = df.merge(df2,on='전화번호',how='left')
# 전화번호에 hypen 추가
finaldf['전화번호'] =finaldf['전화번호'].str[:3] + "-" + finaldf['전화번호'].str[3:7] + "-" + finaldf['전화번호'].str[7:]
test = finaldf['전화번호'].unique()
newdf = pd.DataFrame()
newdf['전화번호'] = test
newdf['암호'] = ["XXX-XXXX-" + str(i).zfill(4) for i in newdf.index]
newdf
finaldf = finaldf.merge(newdf,on='전화번호',how='left')
# 암호만 살리기
finaldf = finaldf[['일자','유형','암호','금액', '최초결제일']]
# 암호 이름 바꾸기
finaldf.columns = ['일자','유형','전화번호','금액', '최초결제일']
# multiply rand
finaldf['금액'] = finaldf['금액'] * randint(85,115) * 0.01
return finaldf
# output is list
class Cafe_user:
def __init__(self, df, number):
self.df = df
self.number = number
@property
def first_date(self):
df = self.df[self.df['전화번호'] == self.number]
return df['일자'].min()
@property
def first_date_str(self):
df = self.df[self.df['전화번호'] == self.number]
return df['일자'].min().strftime('%y년 %m월 %d일')
@property
def last_date(self):
df = self.df[self.df['전화번호'] == self.number]
return df['일자'].max()
@property
def last_date_str(self):
df = self.df[self.df['전화번호'] == self.number]
return df['일자'].max().strftime('%y년 %m월 %d일')
@property
def last_item(self):
df = self.df[self.df['전화번호'] == self.number]
return df.loc[df['일자'] == df['일자'].max(),'유형'].values[0]
@property
def last_amount(self):
df = self.df[self.df['전화번호'] == self.number]
return df.loc[df['일자'] == df['일자'].max(),'금액'].values[0]
class df_property:
def __init__(self, df):
self.df = df
# 결제유형 필터 : 전체, 기간권, 정액권, 시간권, 기타 순
self.type1 = self.df['유형'].str.contains('|'.join(['']), na=False)
self.type2 = self.df['유형'].str.contains('|'.join(['기간권']), na=False)
self.type3 = self.df['유형'].str.contains('|'.join(['정액권']), na=False)
self.type4 = self.df['유형'].str.contains('|'.join(['시간권']), na=False)
self.type5 = self.df['유형'].str.contains('|'.join(['스터디룸', '추가사용요금', '사물함']), na=False)
# 고객유형 필터 : 전체, 당월최초, 기존 순
self.user1 = self.df['유형'].str.contains('|'.join(['']), na=False)
self.user2 = ((self.df['최초결제일'].dt.year == self.df['일자'].dt.year) & (self.df['최초결제일'].dt.month == self.df['일자'].dt.month))
self.user3 = ~ ((self.df['최초결제일'].dt.year == self.df['일자'].dt.year) & (self.df['최초결제일'].dt.month == self.df['일자'].dt.month))
self.monthdf = self.df.resample("M",on='일자').sum()
@property
def num_user(self):
value = self.df['전화번호'].nunique()
return f'{value:,.0f}' # 천의 자리 comma
@property
def month_average(self):
grand_total = self.df["금액"].sum()
days = (self.df['일자'].max() - self.df['일자'].min()).days
months_from_day = days / 30
value = (grand_total / months_from_day) * 0.0001
return f'{value:,.0f}'
@property
def top10_portion(self):
num_user = self.df['전화번호'].nunique()
num_of_ten = round(num_user * 0.1)
top10_sum = self.df.groupby('전화번호')['금액'].sum().sort_values(ascending=False).head(num_of_ten).sum()
grand_total = self.df["금액"].sum()
top10_portion = top10_sum / grand_total
return f'{top10_portion:.0%}'
@property
def top10_month_average(self):
days = (self.df['일자'].max() - self.df['일자'].min()).days
months_from_day = days / 30
num_user = self.df['전화번호'].nunique()
num_of_ten = round(num_user * 0.1)
top10_sum = self.df.groupby('전화번호')['금액'].sum().sort_values(ascending=False).head(num_of_ten).sum()
value = (top10_sum / months_from_day) * ( 0.0001)
return f'{value:,.0f}'
def empty_fig():
fig = px.bar()
fig.update_layout({'plot_bgcolor': 'rgba(0, 0, 0, 0)'})
fig.update_xaxes(showgrid=False, zeroline=False, visible=False)
fig.update_yaxes(showgrid=False, zeroline=False, visible=False)
return fig
def user_stack_chart(df, user):
df = df[df['전화번호'] == user]
# 월데이터로 바꾸기
df.일자 =df.일자.dt.strftime('%Y-%m')
fig = px.bar(df, x = '일자', y = '금액', color =
'유형', barmode = 'stack',color_discrete_sequence=px.colors.qualitative.G10)
fig.update_layout({'plot_bgcolor': 'rgba(0, 0, 0, 0)'})
fig.update_layout(xaxis_range=['2021-01-01','2021-08-31'])
fig.update_layout(title=f"{user}님 상세",title_x=0.5)
fig.update_traces(width=1000 * 3600 * 24 * 31*0.8)
fig.update_xaxes(showgrid=False, zeroline=False, dtick="M1",tickformat="%b\n%Y")
fig.update_yaxes(showgrid=False, zeroline=False, tickformat=',.0f')
# fig.show(config={"displayModeBar": False, "showTips": False})
return fig
def unixTimeMillis(dt):
''' Convert datetime to unix timestamp '''
return int(time.mktime(dt.timetuple()))
def unixToDatetime(unix):
''' Convert unix timestamp to datetime. '''
return pd.to_datetime(unix,unit='s')
def getMarks(myrange, Nth=100):
''' Returns the marks for labeling.
Every Nth value will be used.
'''
result = {}
for i, date in enumerate(myrange):
if(i%Nth == 1):
# Append value to dict
result[unixTimeMillis(date)] = str(date.strftime('%Y-%m-%d'))
return result
####### Create global chart template
layout = dict(
autosize=True,
automargin=True,
margin=dict(l=30, r=30, b=20, t=40),
hovermode="closest",
plot_bgcolor="#F9F9F9",
paper_bgcolor="#F9F9F9",
legend=dict(font=dict(size=10), orientation="h"),
title="Satellite Overview",
mapbox=dict(
accesstoken=mapbox_access_token,
style="light",
center=dict(lon=-78.05, lat=42.54),
zoom=7,
),
)
mytable = dash_table.DataTable(
id='table1',
columns=[{"name": i, "id": i,'type':'numeric', 'format': {'specifier': ',.0f'}} if i not in ['전화번호'] else {"name": i, "id": i} for i in df.columns],
data=df.to_dict('records'),
style_as_list_view=True,
style_cell={'padding': '2px'},
page_action='none',
style_table={'height': '600px', 'overflowY': 'auto'},
style_cell_conditional=[
{
'if': {'column_id': '전화번호'},
'textAlign': 'center'
},
],
style_data_conditional=[
{
'if': {'row_index': 0},
'backgroundColor': '#FFF2CC',
},
],
style_header={
'backgroundColor': 'white',
'fontWeight': 'bold',
'textAlign': 'center'
},
)
user_graph = html.Div(dcc.Graph(
id='user-graph',
config={'displayModeBar':False},
figure = emptyfig,
),
)
############# Create app layout
app.layout = html.Div(
[
dcc.Store(id="aggregate_data"),
# empty Div to trigger javascript file for graph resizing
html.Div(id="output-clientside"),
html.Div(
[
html.Div(
[
html.Img(
src=app.get_asset_url("coffee-cup.png"),
id="plotly-image",
style={
"height": "60px",
"width": "auto",
"margin-bottom": "25px",
},
)
],
className="one-third column",
),
html.Div(
[
html.Div(
[
html.H2(
"XXX OOO점",
style={"margin-bottom": "0px"},
),
html.H5(
"Sales Overview", style={"margin-top": "0px"}
),
]
)
],
className="one-half column",
id="title",
),
html.Div(
[
html.A(
html.Button("서비스 문의", id="learn-more-button"),
href="https://dabid.tistory.com/",
)
],
className="one-third column",
id="button",
),
],
id="header",
className="row flex-display",
style={"margin-bottom": "25px"},
),
html.Div(
[
html.Div(
[
html.P(
"기 간",
className="control_label",
),
dcc.RangeSlider(
id="year_slider",
min = unixTimeMillis(clean_range.min()),
max = unixTimeMillis(clean_range.max()),
value = [unixTimeMillis(clean_range.min()),
unixTimeMillis(clean_range.max())],
marks=getMarks(clean_range,3),
className="dcc_control",
),
html.P("결제 유형", className="control_label"),
dcc.RadioItems(
id="type_select",
options=[
{"label": "전체 ", "value": "type1"},
{"label": "기간권", "value": "type2"},
{"label": "정액권", "value": "type3"},
{"label": "시간권", "value": "type4"},
{"label": "기타", "value": "type5"},
],
value="type1",
labelStyle={"display": "inline-block"},
className="dcc_control",
),
html.P("고객 유형", className="control_label"),
dcc.RadioItems(
id="user_select",
options=[
{"label": "전체", "value": "user1"},
{"label": "당월 최초", "value": "user2"},
{"label": "기존", "value": "user3"}
],
value="user1",
labelStyle={"display": "inline-block"},
className="dcc_control",
),
html.P("정렬기준", className="control_label"),
dcc.RadioItems(
id="sort_select",
options=[
{"label": "기간 합계 ", "value": "sort1"},
{"label": "최근 1개월", "value": "sort2"},
{"label": "최근 3개월", "value": "sort3"},
],
value="sort1",
labelStyle={"display": "inline-block"},
className="dcc_control",
),
],
className="pretty_container four columns",
id="cross-filter-options",
),
html.Div(
[
html.Div(
[
html.Div(
[html.H6(id="usernum_text"), html.P("총 방문자 수")],
id="mini1",
className="mini_container",
),
html.Div(
[html.H6(id="sales_text"), html.P("월 평균 매출")],
id="mini2",
className="mini_container",
),
html.Div(
[html.H6(id="top10sales_text"), html.P("TOP 10%의 매출")],
id="mini3",
className="mini_container",
),
html.Div(
[html.H6(id="top10monthly_text"), html.P("TOP 10%의 \n 월평균 매출 ")],
id="mini4",
className="mini_container",
),
],
id="info-container",
className="row container-display",
),
html.Div(
[dcc.Graph(id="count_graph",config={'displayModeBar':False})],
id="countGraphContainer",
className="pretty_container",
),
],
id="right-column",
className="eight columns",
),
],
className="row flex-display",
),
html.Div(
[
html.Div(
[mytable],
className="pretty_container seven columns",
),
html.Div(
[
html.Div(
[
html.Div(
[html.H6(id="first_date"), html.P("최초 방문일")],
id="wells2",
className="mini_container",
),
html.Div(
[html.H6(id="last_date"), html.P("최종 결제일")],
id="gas2",
className="mini_container",
),
# html.Div(
# [html.H6(id="last_type"), html.P("최종 결제 유형")],
# id="oil2",
# className="mini_container",
# ),
# html.Div(
# [html.H6(id="last_amount"), html.P("최종 결제 금액")],
# id="water2",
# className="mini_container",
# ),
],
id="info-container2",
className="row container-display",
),
html.Div(
[user_graph],
id="countGraphContainer2",
className="pretty_container",
),
],
id="right-column2",
className="five columns",
),
],
className="row flex-display",
),
# html.Div(
# [
# html.Div(
# [dcc.Graph(id="pie_graph")],
# className="pretty_container six columns",
# ),
# html.Div(
# [dcc.Graph(id="aggregate_graph")],
# className="pretty_container six columns",
# ),
# ],
# className="row flex-display",
# ),
],
id="mainContainer",
style={"display": "flex", "flex-direction": "column"},
)
# input is class Cafe_user output is html.div
def user_info(Cafe_user):
# 0,1,2,3 순으로 첫번째 날짜, 마지막 날짜, 마지막 유형, 마지막 금액
div = html.Div(
[
html.Div(f"{Cafe_user.number} 고객 정보",style= {'margin-bottom': '10px'}),
html.Div(f"최초 방문일 : {Cafe_user.first_date_str()}"),
html.Div(f"최종 결제일 : {Cafe_user.last_date_str()}"),
html.Div(f"최종 결제 유형 : {Cafe_user.last_item()}"),
html.Div(f"최총 결제 금액 : {Cafe_user.last_amount():,}")
],
style= {'margin-bottom': '40px','font-size' : '18px','font-weight': '400'}
)
return div
#get aggregate date from filter
@app.callback(
Output("aggregate_data", "data"),
[
Input("type_select", "value"),
Input("user_select", "value"),
Input("year_slider", "value"), # mydates is a lists , access with [0] and [1]
],
)
def update_sales_text(type_filter, user_filter, mydates):
startdate = np.datetime64(mydates[0],'s')
enddate = np.datetime64(mydates[1],'s')
date_filter = (clean_df['일자'] >= startdate) & (clean_df['일자'] <= enddate)
dff = clean_df[myfilters[type_filter] & myfilters[user_filter] & date_filter]
data = df_property(dff)
d1 = data.num_user
d2 = data.month_average
d3 = data.top10_portion
d4 = data.top10_month_average
return [d1,d2,d3,d4]
# get texts from aggregate_data
@app.callback(
[
Output("usernum_text", "children"),
Output("sales_text", "children"),
Output("top10sales_text", "children"),
Output("top10monthly_text", "children"),
],
[Input("aggregate_data", "data")],
)
def update_text(data):
return data[0],data[1]+' 만원',data[2],data[3] +'만원'
# get first graph
@app.callback(
Output("count_graph", "figure"),
[
Input("type_select", "value"),
Input("user_select", "value"),
Input("year_slider", "value"), # mydates is a lists , access with [0] and [1]
],
)
def make_first_figure(type_filter, user_filter, mydates):
startdate = np.datetime64(mydates[0],'s')
enddate = np.datetime64(mydates[1],'s')
# date_filter = (clean_df['일자'] >= startdate) & (clean_df['일자'] <= enddate)
df = clean_df[myfilters[type_filter] & myfilters[user_filter]]
myprop = df_property(df)
monthdf = myprop.monthdf
layout_count = copy.deepcopy(layout)
colors = []
for i in clean_range:
if i >= startdate and i < enddate:
colors.append("rgb(123, 199, 255)")
else:
colors.append("rgba(123, 199, 255, 0.2)")
## scatter dict은 왜있는지 모르겠음...
data = [
dict(
type="bar",
x=clean_range,
y=monthdf["금액"],
name="매출액(만원)",
marker=dict(color=colors),
),
]
layout_count["title"] = "월별 매출"
layout_count["dragmode"] = "select"
layout_count["showlegend"] = False
layout_count["xaxis"] = {'type': 'date', 'tick0': clean_range[0], 'tickformat' : '%b\n%Y', 'tickmode': 'linear', 'dtick': 86400000.0 * 104} # 14 days
layout_count["yaxis"] = {'tickformat' : ',.0f', 'tickmode' : 'auto','automargin': True} # 14 days
figure = dict(data=data, layout=layout_count)
return figure
@app.callback(
Output('user-graph', 'figure'),
Output("first_date", "children"), # 최초 방문
Output("last_date", "children"), # 최종 결제
Input('table1', 'active_cell'),
State('table1', 'data')
)
def getActiveCell(active_cell, data):
if active_cell:
col = active_cell['column_id']
row = active_cell['row']
cellData = data[row][col]
fig = user_stack_chart(clean_df, cellData)
fig.update_layout(showlegend=False)
myuser = Cafe_user(clean_df, cellData)
t1 = myuser.first_date_str
t2 = myuser.last_date_str
# t3 = myuser.last_item
# t4 = myuser.last_amount
# t1 = f"{t1}"
t2 = f"{t2}"
# t3 = f"{t3}"
# t4 = f"{t4:,}"
return fig, t1,t2
return emptyfig, "",""
def get_pivot(df):
# duration_month = 6
# 그 다음 피벗테이블
df['연월']=df['일자'].dt.strftime('%y-%m')
df2 = df.pivot_table(index='전화번호',columns='연월',values='금액',margins=True,aggfunc=np.sum).sort_values(by='All', ascending=False).head(100)
df2 = df2.reset_index()
# All column을 합계로 바꾸기
df2.columns = df2.columns.str.replace('All', '합계')
# 총 매출(월별)
df2['전화번호'] = df2['전화번호'].str.replace('All', '총 매출(월)')
pd.options.display.float_format = '{:,.0f}'.format
return df2
#get aggregate date from filter
@app.callback(
Output('table1', 'data'),
Output('table1', 'columns'),
[
Input("type_select", "value"),
Input("user_select", "value"),
Input("year_slider", "value"),
Input("sort_select", "value"),# mydates is a lists , access with [0] and [1]
],
)
def update_sales_text(type_filter, user_filter, mydates, sorttype):
startdate = np.datetime64(mydates[0],'s')
enddate = np.datetime64(mydates[1],'s')
date_filter = (clean_df['일자'] >= startdate) & (clean_df['일자'] <= enddate)
dff = clean_df[myfilters[type_filter] & myfilters[user_filter] & date_filter]
if sorttype == "sort1":
df = get_pivot(dff)
columns=[{"name": i, "id": i,'type':'numeric', 'format': {'specifier': ',.0f'}} if i not in ['전화번호'] else {"name": i, "id": i} for i in df.columns]
return df.to_dict('records'), columns
elif sorttype == "sort2":
df = get_pivot(dff)
df = df.sort_values(df.columns[-2], ascending=False)
columns=[{"name": i, "id": i,'type':'numeric', 'format': {'specifier': ',.0f'}} if i not in ['전화번호'] else {"name": i, "id": i} for i in df.columns]
return df.to_dict('records'), columns
elif sorttype == "sort3":
df = get_pivot(dff)
months = (len(df.columns)-1) if len(df.columns) < 4 else 3
newcolumn = f"{months} 개월 합계"
mylist = [(-i-2) for i in range(0,months)] # months가 3이면 -2,-3,-4 2이면 -2,-3
mycolumns = [df[df.columns[i]] for i in mylist]
df[newcolumn] = sum(mycolumns)
# 합계 열은 없애기
df = df.drop(df.columns[-2],1)
df = df.sort_values(df.columns[-1], ascending=False)
columns=[{"name": i, "id": i,'type':'numeric', 'format': {'specifier': ',.0f'}} if i not in ['전화번호'] else {"name": i, "id": i} for i in df.columns]
return df.to_dict('records'), columns
if __name__ == '__main__':
app.run_server(debug=True, host='0.0.0.0')
- 코드가 너무 길어서 이 페이지에 모두 담아내기는 힘들 것 같다.
코멘트
- Class을 왜 사용하는지 항상 궁금해 했었는데, Class가 이렇게 유용할 수 있다는 점을 이번 프로젝트를 통해서 알게 되었다.
- 특정 고객에 대한 모든 사항을 Class 안에서 해결하니 코드를 직관적으로 관리할 수 있다.
- 특정 기간, 필터에 대한 dataframe 및 거기서 파생되는 여러 속성도 하나의 Class 안에서 정리를 하다보니 중복되는 코드를 많이 방지할 수 있었다.
- Input이 String인 경우에 이를 활용하여 특정 함수를 호출하는 유용한 방법을 알게 되었고, 적용하게 되었다.
- 만약 input이 'hello'라는 string인데, 실제로 나는 hello라는 변수를 parameter로 하는 함수를 호출하고 싶음
→ 먼저 dictionary를 만들어서 그 string을 내가 원하는 함수에 대응시키고, 이후에 dicitonary의 key(string)를 호출하는 방식으로 함수 호출
- 만약 input이 'hello'라는 string인데, 실제로 나는 hello라는 변수를 parameter로 하는 함수를 호출하고 싶음