Spring Boot 3とVue 3を用いたフロントエンド・バックエンド分離プロジェクトでのECharts統合

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>

タグ: Spring Boot Vue 3 ECharts 前後端分離 データ可視化

5月15日 08:48 投稿