2、バックエンド実装
2.1 月間販売数量
@Operation(summary = "製品別月間販売数量の統計")
@PostMapping("/monthlySales")
public SaResult monthlySales(@RequestBody DateRangeDTO dateRangeDTO){
LocalDate startDate = LocalDate.parse(dateRangeDTO.getStartDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd"));
LocalDate endDate;
if(startDate.getYear() < LocalDate.now().getYear()){
// 過去年の終了日はその年の最終日とする
endDate = startDate.with(TemporalAdjusters.lastDayOfYear());
}else{
// 今年の終了日は本日とする
endDate = LocalDate.now();
}
// 終了日の月を取得
Month endMonth = endDate.getMonth();
// 現在の月とそれ以前の月のみを含むセットを作成
Set<Month> months = IntStream.rangeClosed(1, endMonth.getValue())
.mapToObj(Month::of)
.collect(Collectors.toSet());
List<SalesRecord> salesRecords = salesRecordService.findAll();
// 製品リスト
List<Product> products = productService.findAll();
// statisticsの初期化
Map<String, Map<Month, Integer>> salesStats = products.stream()
.distinct()
.collect(Collectors.toMap(
Product::getId,
product -> months.stream().collect(Collectors.toMap(Function.identity(), month -> 0))));
for (SalesRecord record : salesRecords) {
LocalDate recordDate = record.getCreationTime().toLocalDate();
// 開始日以前でなく、終了日以降でなく(開始日と終了日を含む)、タイプ(1:購入、2:販売)
if(!recordDate.isBefore(startDate) && !recordDate.isAfter(endDate) && record.getType()==2){
Month month = recordDate.getMonth();
String productId = record.getProductId();
Integer quantity = record.getQuantity();
// mergeメソッドの第一引数はキー(ここでは月)、第二引数は新しい値(数量)、第三引数はマージ関数
salesStats.get(productId).merge(month, quantity, Integer::sum);
}
}
return SaResult.ok().setData(salesStats);
}
月間販売数量APIのリクエストに対するバックエンドからのレスポンス
{
"code": 200,
"msg": "ok",
"data": {
"e4c11c628c443ba16ac575c7288bc619": {
"MARCH": 0,
"JANUARY": 0,
"APRIL": 0,
"JULY": 0,
"MAY": 0,
"FEBRUARY": 0,
"JUNE": 0
},
"7c4ac98bd384722c4d028a95ea8f118e": {
"MARCH": 0,
"JANUARY": 0,
"APRIL": 0,
"JULY": 2,
"MAY": 0,
"FEBRUARY": 0,
"JUNE": 0
},
"c22d5558a8b7ce6d7ec6ca309395a2f8": {
"MARCH": 0,
"JANUARY": 0,
"APRIL": 0,
"JULY": 0,
"MAY": 0,
"FEBRUARY": 0,
"JUNE": 2
}
}
}
2.2 月間販売額
@Operation(summary = "月間販売額の統計")
@PostMapping("/monthlyRevenue")
public SaResult monthlyRevenue(@RequestBody DateRangeDTO dateRangeDTO){
LocalDate startDate = LocalDate.parse(dateRangeDTO.getStartDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd"));
LocalDate endDate;
if(startDate.getYear() < LocalDate.now().getYear()){
// 過去年の終了日はその年の最終日とする
endDate = startDate.with(TemporalAdjusters.lastDayOfYear());
}else{
// 今年の終了日は本日とする
endDate = LocalDate.now();
}
// 終了日の月を取得
Month endMonth = endDate.getMonth();
// 現在の月とそれ以前の月のみを含むセットを作成
Set<Month> months = IntStream.rangeClosed(1, endMonth.getValue())
.mapToObj(Month::of)
.collect(Collectors.toSet());
List<SalesRecord> salesRecords = salesRecordService.findAll();
// statisticsの初期化
Map<Month, Double> revenueStats = months.stream().collect(Collectors.toMap(Function.identity(), month -> 0.0));
for (SalesRecord record : salesRecords) {
LocalDate recordDate = record.getCreationTime().toLocalDate();
// 開始日以前でなく、終了日以降でなく(開始日と終了日を含む)、タイプ(1:購入、2:販売)
if(!recordDate.isBefore(startDate) && !recordDate.isAfter(endDate) && record.getType()==2){
Month month = recordDate.getMonth();
Double amount = record.getAmount();
// mergeメソッドの第一引数はキー(ここでは月)、第二引数は新しい値(金額)、第三引数はマージ関数
revenueStats.merge(month, amount, Double::sum);
}
}
return SaResult.ok().setData(revenueStats);
}
月間販売額APIのリクエストに対するバックエンドからのレスポンス
{
"code": 200,
"msg": "ok",
"data": {
"MARCH": 0,
"JANUARY": 0,
"APRIL": 0,
"JULY": 1200,
"MAY": 0,
"FEBRUARY": 0,
"JUNE": 600
}
}
2.3 在庫状況
@Operation(summary = "在庫状況の統計")
@GetMapping("/inventoryStats")
public SaResult inventoryStats(){
List<Product> products = productService.findAll();
// statisticsの初期化
Map<String, Integer> inventoryStats = new HashMap<>();
for (Product product : products) {
inventoryStats.put(product.getName(), product.getStock());
}
return SaResult.ok().setData(inventoryStats);
}
在庫状況APIのリクエストに対するバックエンドからのレスポンス
{
"code": 200,
"msg": "ok",
"data": {
"製品1": 1,
"製品3": 1,
"製品2": 0
}
}
2.4 月間販売返品数
@Operation(summary = "製品別月間販売返品数の統計")
@PostMapping("/returnStats")
public SaResult returnStats(@RequestBody DateRangeDTO dateRangeDTO){
LocalDate startDate = LocalDate.parse(dateRangeDTO.getStartDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd"));
LocalDate endDate;
if(startDate.getYear() < LocalDate.now().getYear()){
// 過去年の終了日はその年の最終日とする
endDate = startDate.with(TemporalAdjusters.lastDayOfYear());
}else{
// 今年の終了日は本日とする
endDate = LocalDate.now();
}
// 終了日の月を取得
Month endMonth = endDate.getMonth();
// 現在の月とそれ以前の月のみを含むセットを作成
Set<Month> months = IntStream.rangeClosed(1, endMonth.getValue())
.mapToObj(Month::of)
.collect(Collectors.toSet());
List<ReturnRecord> returnRecords = returnRecordService.findAll();
// 製品リスト
List<Product> products = productService.findAll();
// statisticsの初期化
Map<String, Map<Month, Integer>> returnStats = products.stream()
.distinct()
.collect(Collectors.toMap(
Product::getId,
product -> months.stream().collect(Collectors.toMap(Function.identity(), month -> 0))));
for (ReturnRecord record : returnRecords) {
LocalDate recordDate = record.getCreationTime().toLocalDate();
// 開始日以前でなく、終了日以降でなく(開始日と終了日を含む)、タイプ(1:購入返品、2:販売返品)
if(!recordDate.isBefore(startDate) && !recordDate.isAfter(endDate) && record.getType()==2){
Month month = recordDate.getMonth();
SalesRecord salesRecord = salesRecordService.findById(record.getSalesRecordId());
Product product = productService.findById(salesRecord.getProductId());
String productId = product.getId();
Integer quantity = record.getQuantity();
// mergeメソッドの第一引数はキー(ここでは月)、第二引数は新しい値(数量)、第三引数はマージ関数
returnStats.get(productId).merge(month, quantity, Integer::sum);
}
}
return SaResult.ok().setData(returnStats);
}
月間販売返品数APIのリクエストに対するバックエンドからのレスポンス
{
"code": 200,
"msg": "ok",
"data": {
"e4c11c628c443ba16ac575c7288bc619": {
"MARCH": 0,
"JANUARY": 0,
"APRIL": 0,
"JULY": 0,
"MAY": 0,
"FEBRUARY": 0,
"JUNE": 0
},
"7c4ac98bd384722c4d028a95ea8f118e": {
"MARCH": 0,
"JANUARY": 0,
"APRIL": 0,
"JULY": 1,
"MAY": 0,
"FEBRUARY": 0,
"JUNE": 0
},
"c22d5558a8b7ce6d7ec6ca309395a2f8": {
"MARCH": 0,
"JANUARY": 0,
"APRIL": 0,
"JULY": 0,
"MAY": 0,
"FEBRUARY": 0,
"JUNE": 0
}
}
}
3、フロントエンド実装
<template>
<el-card class="container">
<template #header>
<div class="header">
<el-breadcrumb :separator-icon="ArrowRight">
<el-breadcrumb-item :to="{ path: '/home/index' }" class="title">ホーム</el-breadcrumb-item>
<el-breadcrumb-item class="title">製品管理</el-breadcrumb-item>
<el-breadcrumb-item class="title">製品統計</el-breadcrumb-item>
</el-breadcrumb>
</div>
</template>
<div class="top">
<div class="date-picker">
<el-date-picker
v-model="selectedYear"
type="year"
format="YYYY"
value-format="YYYY-MM-DD"
@change="handleYearChange"
/>
</div>
<div class="statistics">
<el-form inline>
<el-form-item label="年間販売額(+)">
<el-input v-model="totalSales" :type="salesVisible ? 'text' : 'password'" disabled >
<template #append>
<el-button :icon="salesVisible ? Hide : View" @click="toggleSalesVisibility" />
</template>
</el-input>
</el-form-item>
<el-form-item label="年間コスト(-)">
<el-input v-model="totalCost" :type="costVisible ? 'text' : 'password'" disabled>
<template #append>
<el-button :icon="costVisible ? Hide : View" @click="toggleCostVisibility" />
</template>
</el-input>
</el-form-item>
<el-form-item label="年間販売返品額(-)">
<el-input v-model="returnSales" :type="returnSalesVisible ? 'text' : 'password'" disabled>
<template #append>
<el-button :icon="returnSalesVisible ? Hide : View" @click="toggleReturnSalesVisibility" />
</template>
</el-input>
</el-form-item>
<el-form-item label="年間購入返品額(+)">
<el-input v-model="returnPurchases" :type="returnPurchasesVisible ? 'text' : 'password'" disabled>
<template #append>
<el-button :icon="returnPurchasesVisible ? Hide : View" @click="toggleReturnPurchasesVisibility" />
</template>
</el-input>
</el-form-item>
<el-form-item label="年間利益">
<el-input v-model="netProfit" :type="profitVisible ? 'text' : 'password'" disabled>
<template #append>
<el-button :icon="profitVisible ? Hide : View" @click="toggleProfitVisibility" />
</template>
</el-input>
</el-form-item>
</el-form>
</div>
</div>
<el-scrollbar height="670px">
<div class="mycharts">
<!-- 1、各製品月間販売数量 -->
<div id="salesVolumeChart" class="mychart"></div>
<!-- 2、月間販売総額 -->
<div id="salesRevenueChart" class="mychart"></div>
</div>
<div class="mycharts">
<!-- 3、在庫状況 -->
<div id="inventoryChart" class="mychart"></div>
<!-- 4、月間販売返品数 -->
<div id="returnsChart" class="mychart"></div>
</div>
</el-scrollbar>
</el-card>
</template>
<script setup lang="ts">
import salesApi from '@/api/product/sales';
import productApi from '@/api/product/product';
import { onMounted, reactive, ref } from 'vue'
import { ArrowRight, View, Hide } from '@element-plus/icons-vue'
import * as echarts from 'echarts';
import returnApi from '@/api/product/returns';
let selectedYear = ref('2024-01-01')
// 1:年間コスト(購入総額+販売送料+返品送料)、2:年間販売額、3:年間利益
let financialData = reactive(new Map()) as any;
// 1:年間購入返品総額、2:年間販売返品総額、3:年間返品送料
let returnData = reactive(new Map()) as any;
// 年間コスト(-):購入総額+販売送料+返品送料
const totalCost = ref(0.00)
// 年間販売額(+)
const totalSales = ref(0.00)
// 年間利益
const netProfit = ref(0.0)
// 年間販売返品額(-)
const returnSales = ref(0.00)
// 年間購入返品額(+)
const returnPurchases = ref(0.00)
const costVisible = ref(false)
const salesVisible = ref(false)
const profitVisible = ref(false)
const returnSalesVisible = ref(false)
const returnPurchasesVisible = ref(false)
const toggleCostVisibility = () => {
costVisible.value = !costVisible.value;
}
const toggleSalesVisibility = () => {
salesVisible.value = !salesVisible.value;
}
const toggleProfitVisibility = () => {
profitVisible.value = !profitVisible.value;
}
const toggleReturnSalesVisibility = () => {
returnSalesVisible.value = !returnSalesVisible.value;
}
const toggleReturnPurchasesVisibility = () => {
returnPurchasesVisible.value = !returnPurchasesVisible.value;
}
const initializeData = () => {
totalCost.value = financialData.get(1);
totalSales.value = financialData.get(2);
returnSales.value = returnData.get(2);
returnPurchases.value = returnData.get(1);
netProfit.value = (totalSales.value + returnPurchases.value) - (totalCost.value + returnSales.value);
}
// 1:年間コスト(購入総額+販売送料+返品送料)、2:年間販売額、3:年間利益
const fetchFinancialData = async() => {
const response = await salesApi.yearStatistics(selectedYear.value);
for (const [key, value] of Object.entries(response.data)) {
const numericKey = Number(key);
financialData.set(numericKey, value);
}
// コストに返品送料を追加
financialData.set(1, financialData.get(1) + returnData.get(3));
initializeData();
}
// 1:年間購入返品総額、2:年間販売返品総額、3:年間返品送料
const fetchReturnData = async() => {
const response = await returnApi.yearStatistics(selectedYear.value);
for (const [key, value] of Object.entries(response.data)) {
const numericKey = Number(key);
returnData.set(numericKey, value);
}
fetchFinancialData();
}
// 1、各製品月間販売数量 折れ線グラフ
const drawSalesVolumeChart = async() => {
// 設定オプション
const option = reactive({
title: {
text: '月間販売数量',
top: 5,
},
// 凡例の設定
legend:{
data: [],
top: 10
},
tooltip: {
trigger:"axis", // 軸に基づいてトリガー
},
xAxis: {
type: 'category',
data: []
},
yAxis: {
type: 'value'
},
// 選択時の強調表示
emphasis:{
focus:"series"
},
series: []
});
// xAxisData はバックエンドからのデータに適合
const xAxisData = reactive(['JANUARY', 'FEBRUARY', 'MARCH', 'APRIL', 'MAY', 'JUNE', 'JULY', 'AUGUST', 'SEPTEMBER', 'OCTOBER', 'NOVEMBER', 'DECEMBER']) as any
// xData はフロントエンド表示用
const xData = reactive(['1月', '2月', '3月', '4月','5月', '6月', '7月', '8月','9月', '10月', '11月', '12月']) as any
// seriesデータ
const seriesData = reactive([]) as any;
// 凡例
let legendData = reactive([]) as any;
// バックエンドからのmapデータ
let salesMap = reactive(new Map()) as any;
// Echartsインスタンスの初期化
const myChart = echarts.init(document.getElementById("salesVolumeChart"));
// 折れ線グラフ x軸データの設定
option.xAxis.data = xData;
// バックエンドデータをy軸に適合するデータに変換(map→list)
(async() => {
const response = await salesApi.monthlySales(selectedYear.value);
for (const [key, value] of Object.entries(response.data)) {
salesMap.set(key, value);
}
return salesMap;
})().then(async() => {
// 全製品名の取得を待つPromise配列を作成
const promises = Array.from(salesMap.entries()).map(async ([key, value] : any) => {
const res = await productApi.getNameById(key);
seriesData.push({
name: res.data,
type: 'line',
smooth: true,
data: xAxisData.map((month:any) => value[month]),
});
// 同時に凡例データに追加
legendData.push(res.data);
});
// すべてのPromiseが完了するのを待つ
await Promise.all(promises);
// チャート設定の更新
option.series = seriesData;
option.legend.data = legendData;
// チャートの描画
myChart.setOption(option);
});
}
// 2、月間販売総額 折れ線グラフ
const drawSalesRevenueChart = async() => {
// 設定オプション
const option = reactive({
title: {
text: '月間販売総額',
top: 5,
},
// 凡例の設定
legend:{
data: [],
top: 10
},
tooltip: {
trigger:"axis", // 軸に基づいてトリガー
},
xAxis: {
type: 'category',
data: []
},
yAxis: {
type: 'value'
},
// 選択時の強調表示
emphasis:{
focus:"series"
},
series: []
});
// xAxisData はバックエンドからのデータに適合
const xAxisData = reactive(['JANUARY', 'FEBRUARY', 'MARCH', 'APRIL', 'MAY', 'JUNE', 'JULY', 'AUGUST', 'SEPTEMBER', 'OCTOBER', 'NOVEMBER', 'DECEMBER']) as any
// xData はフロントエンド表示用
const xData = reactive(['1月', '2月', '3月', '4月','5月', '6月', '7月', '8月','9月', '10月', '11月', '12月']) as any
// seriesデータ
let seriesData = reactive([]) as any;
// 凡例
let legendData = reactive([]) as any;
// バックエンドからのmapデータ
let revenueMap = reactive(new Map()) as any;
// Echartsインスタンスの初期化
const myChart = echarts.init(document.getElementById("salesRevenueChart"));
// 折れ線グラフ x軸データの設定
option.xAxis.data = xData;
// バックエンドデータをy軸に適合するデータに変換(map→list)
(async() => {
const response = await salesApi.monthlyRevenue(selectedYear.value);
for (const [key, value] of Object.entries(response.data)) {
revenueMap.set(key, value);
}
return revenueMap;
})().then(() => {
seriesData.push({
name: '月間販売総額',
type: 'line',
smooth: true,
data: xAxisData.map((month:any) => revenueMap.get(month))
});
// 凡例データの設定
legendData.push('月間販売総額');
// チャート設定の更新
option.series = seriesData;
option.legend.data = legendData;
// チャートの描画
myChart.setOption(option);
});
}
// 3、在庫状況 円グラフ
const drawInventoryChart = async() => {
// 設定オプション
const option = reactive({
title: {
text: '在庫状況',
top: 5,
},
// 凡例の設定
legend:{
data: [],
top: 40,
left:"left",
orient:"vertical", // 縦方向に配置
},
tooltip: {},
// 選択時の強調表示
emphasis:{
focus:"series"
},
series: []
});
// seriesデータ
let seriesData = reactive([]) as any;
// 凡例
let legendData = reactive([]) as any;
// バックエンドからのmapデータ
let inventoryMap = reactive(new Map()) as any;
// Echartsインスタンスの初期化
const myChart = echarts.init(document.getElementById("inventoryChart"));
// バックエンドデータをy軸に適合するデータに変換(map→list)
(async() => {
const response = await productApi.inventoryStats();
for (const [key, value] of Object.entries(response.data)) {
inventoryMap.set(key, value);
}
return inventoryMap;
})().then(() => {
seriesData.push({
type: 'pie',
data: [],
radius: '70%',
label:{
show:true,
formatter: `{b}:{c}`,
position: "outside", //outside 外部表示 inside 内部表示
}
});
inventoryMap.forEach((value:any, key:any) => {
seriesData[0].data.push({ name: key, value: value });
// 凡例の設定
legendData.push(key);
});
// チャート設定の更新
option.series = seriesData;
option.legend.data = legendData;
// チャートの描画
myChart.setOption(option);
});
}
// 4、各製品月間販売返品数 折れ線グラフ
const drawReturnsChart = async() => {
// 設定オプション
const option = reactive({
title: {
text: '月間販売返品数',
top: 5,
},
// 凡例の設定
legend:{
data: [],
top: 10
},
tooltip: {
trigger:"axis", // 軸に基づいてトリガー
},
xAxis: {
type: 'category',
data: []
},
yAxis: {
type: 'value'
},
// 選択時の強調表示
emphasis:{
focus:"series"
},
series: []
});
// xAxisData はバックエンドからのデータに適合
const xAxisData = reactive(['JANUARY', 'FEBRUARY', 'MARCH', 'APRIL', 'MAY', 'JUNE', 'JULY', 'AUGUST', 'SEPTEMBER', 'OCTOBER', 'NOVEMBER', 'DECEMBER']) as any
// xData はフロントエンド表示用
const xData = reactive(['1月', '2月', '3月', '4月','5月', '6月', '7月', '8月','9月', '10月', '11月', '12月']) as any
// seriesデータ
const seriesData = reactive([]) as any;
// 凡例
let legendData = reactive([]) as any;
// バックエンドからのmapデータ
let returnMap = reactive(new Map()) as any;
// Echartsインスタンスの初期化
const myChart = echarts.init(document.getElementById("returnsChart"));
// 折れ線グラフ x軸データの設定
option.xAxis.data = xData;
// バックエンドデータをy軸に適合するデータに変換(map→list)
(async() => {
const response = await returnApi.returnStats(selectedYear.value);
for (const [key, value] of Object.entries(response.data)) {
returnMap.set(key, value);
}
return returnMap;
})().then(async() => {
// 全製品名の取得を待つPromise配列を作成
const promises = Array.from(returnMap.entries()).map(async ([key, value] : any) => {
const res = await productApi.getNameById(key);
seriesData.push({
name: res.data,
type: 'line',
smooth: true,
data: xAxisData.map((month:any) => value[month]),
});
// 同時に凡例データに追加
legendData.push(res.data);
});
// すべてのPromiseが完了するのを待つ
await Promise.all(promises);
// チャート設定の更新
option.series = seriesData;
option.legend.data = legendData;
// チャートの描画
myChart.setOption(option);
});
}
const handleYearChange = () => {
drawSalesVolumeChart();
drawSalesRevenueChart();
drawInventoryChart();
drawReturnsChart();
fetchReturnData();
}
onMounted(() => {
drawSalesVolumeChart();
drawSalesRevenueChart();
drawInventoryChart();
drawReturnsChart();
fetchReturnData();
})
</script>
<style scoped lang="less">
.container{
height: 100%;
box-sizing: border-box;
}
.header{
display: flex;
align-items: center;
justify-content: space-between;
}
.title{
font-size: large;
font-weight: 600;
}
.mycharts{
display: flex;
}
/* 幅と高さを設定しないと表示されない */
.mychart{
width: 100%;
height: 320px;
border: 1px solid pink;
margin: 5px;
}
.date-picker{
margin-left: 5px;
margin-bottom: 5px;
margin-right: 20px;
}
.statistics .el-form .el-form-item .el-input{
width: 150px;
}
.top{
display: flex;
justify-content: space-between;
height: 40px;
}
</style>