この記事は、AEON Advent Calendar 2024の7日目です🎉
はじめまして!
イオンリテールという、AEONグループの総合小売業の企業で働く渡邊と申します。
総合小売業とは衣・食・住・H&BCに各テナントを合わせた広い領域を扱う小売業となります。そこで現在私はネットスーパーの現場オペレーション効率化に関する部署におり、本記事ではそこで作ったアプリケーションのお話をしたいかなと思います。
1. 誰でも一瞬でエクセル処理できる環境が必要になった
ネットスーパーのイメージがわかない方も多くいらっしゃるかと思いますので、少し背景についてお話します
そんなのいらん!技術的な話だけで十分!という方は次の章からお読みください
1.1. 欠品・代替処理の必要性
弊社で実施しているネットスーパーは
売場から商品を集荷(ピッキングと呼んでいます)、店舗から出荷する形態をとっています。ですので、webの画面と店舗の実在庫にタイムラグがあり、お客さまが注文したタイミングでは商品が存在したものの、実際の集荷のタイミングでは商品が売れてしまったということが発生します。
売場で商品全体を集荷し、バックルームの作業場でお客さま毎に商品を振り分ける、という作業の際、そのような欠品・代替品の情報を作業場に引き継がなければなりません。
1.2. 売場ピッキング履歴のエクセルデータ処理
現在弊社では、下写真のような手持ち端末を利用して、ピッキング作業を行う方法の導入を進めています
ですので、欠品・代替品に関する売場での処理自体はデータとして存在しています。ですが、現在150店舗ほどでは作業場での商品振り分け時に、過去から利用している別の振り分けシステムを利用しているため、データが引き継がれません。
そのため、売場ピッキングの全履歴が記載されたエクセルを、
- 管理画面のwebからダウンロードして、
- 欠品代替情報だけを表示させて、
- 印刷して見ながら参照する
という手順が発生するのです。
ですがこのエクセル処理がなかなかの曲者で、
- 32列あるエクセルファイルから、必要な列だけを表示させる
- 明細修正の列が
"有"
の行でフィルターする - A4で印刷されるように横幅を調整する
ことが必要となります。
この作業が現場150店舗で1日6~10回程度発生します。実施するのはお店のパートタイマーさんです。道行くお姉さんに実施してもらうことをイメージしてもらうとわかりやすいかと思います。もちろん、「そんなの楽勝~♪おけ丸水産🐡」という方もいらっしゃるのですが、多くの方にとっては苦行です。
結果として従来の紙でメモを取って、売場の欠品・代替情報を作業場に引き継ぐ方式から脱却できませんでした。
こんなの、できるわけない…
これを現場で迅速に実行できるようにするというのが、今回の企画となります
こんな感じのエクセルファイルを処理しないといけません
何が何だかわかりませんね
これを何とかしてくれ、という現場からの悲鳴が上がりましたが、開発側も簡単には動けません。ですので、これを解決するものを実装した、というのが本記事の趣旨となります。
2. デスクトップアプリとして実装
上記課題を解決するために、デスクトップアプリでの実装を選択しました
- 1度インストールしてしまえば基本無料で使える
- エクセル自体はPCにダウンロードするので、システム上で処理するのが早い
- アプリ立ち上げ ⇒ ファイルをドラッグ&ドロップ ⇒ 目的のエクセルファイルを生成、というシンプルなフローにしたい
という理由です。
選択した技術スタックは以下の通り
-
Next.js
- 言わずと知れたReactベースのフロントエンドフレームワーク
- webアプリケーションのフロントからバックエンドも実装できます
- 後の部分に記述しますが、バージョン更新には注意が必要です
-
Tauri
- 今回の肝
-
Rust
で処理を、HTML
,CSS
,JS
のweb技術でフロントを記述して、デスクトップアプリを実装できます - Next.jsで記述したフロントを利用したデスクトップアプリも、これで実装できます
2.1. Next.jsの利点
フロント側はNext.jsを採用しました。一般的にNext.jsは大規模なフルスタックWebアプリケーションというイメージが強いかもしれませんが、以下の理由でハッカソンのように素早くプロトタイプを構築する場面でもメリットがあります。
Tauriと組み合わせる上でのメリットを白塗りで表示
Shadcn/UIの導入が容易
V0との相性が良い
- Vercelへのデプロイが迅速
- バックエンド側も一貫して実装できる
そのような理由でNext.jsに慣れていたので利用しました。
特に、V0にフロントを書かせる場合、デフォルトでShadcn/UIを使いますので、実装初期の工程をかなり削減できます。
もちろん、Next.jsである必然性はありませんので、よくあるVite + React、もしくは純粋なHTML, CSS, JSでも問題ありません。
2.2 Tauriの利点
デスクトップアプリを実装する上で、私の技術スタックからPythonによる実装、Electronによる実装、PowerAutomate Desktopによる実装も候補に挙がりました。それぞれと比較した際のメリットを記述します。
2.2.1. Python(Pyinstaller)との比較
- Webのフロントを流用できるため、リッチなUIを容易に実装しやすい
- バンドルサイズが小さい
以前TkinterとPyinstallerで、私が個人的に自動化していたPythonのコードをアプリ化して他の人でも使えるようにしたことがあったのですが、その際フロントを実装するところで非常に苦労した記憶がありました。自分のイメージ通りにフロントを実装できないことは非常にストレスが溜まりますので、もうやりたくないと思い除外しました。
2.2.2. Electronとの比較
- バンドルサイズが小さい
Rustが勉強できる
Electron自体を触ったことはなかったのですが、ドキュメント読んでいけそうだなとは思いました。ですが、Node.jsとChromiumをバンドルに加えるためビルド後のアプリケーションが大きくなるとのことです。店舗に配布することを考えるとあまり大きなファイルにしたくないと思い除外しました。
Rustを勉強できることをメリット取るか、デメリットと取るかはあなた次第です。
2.2.3. PowerAutomate Desktopとの比較
- 機能制限がほとんどない
PowerAutomate Desktopというローコードの自動化アプリを利用することも考えました。ですが、無料ではエクセルの編集機能は、制限されていた記憶がありました。せっかく実装し始めたものの、制限で頓挫するのは非常に気分が悪いので除外しました。
3. 実装
基本的なJavaScriptの開発環境(Node.jsとか)、Rustの開発環境(CargoとかRust analyzer)は準備しておきましょう
Rust初めての方は次のページに従って準備してみましょう
Tauri自体は次のページに従ってcreate-tauri-app
で準備できます。ですが、Next.jsを利用する場合少しだけ注意点があります。
3.1. Next.jsの準備
次のページに記述があるように2024年12月7日現在、TauriはNext.jsのバージョン14.2.3での動作を保証しています。ですので、次のコマンドでバージョン指定をしましょう
bunx create-next-app@14.2
次に、Next.jsのビルドをTauriで利用できる形式に変更します。create-next-appで生成されたnext.conf.mjs
に次のコードを貼り付けます
const isProd = process.env.NODE_ENV === 'production';
const internalHost = process.env.TAURI_DEV_HOST || 'localhost';
/** @type {import('next').NextConfig} */
const nextConfig = {
// Ensure Next.js uses SSG instead of SSR
// https://nextjs.org/docs/pages/building-your-application/deploying/static-exports
output: 'export',
// Note: This feature is required to use the Next.js Image component in SSG mode.
// See https://nextjs.org/docs/messages/export-image-api for different workarounds.
images: {
unoptimized: true,
},
// Configure assetPrefix or else the server won't properly resolve your assets.
assetPrefix: isProd ? undefined : `http://${internalHost}:3000`,
};
export default nextConfig;
詳細は次のページにあるので、確認してみてください
3.2 Tauriの準備
基本的には次のページにあるManual Setup (Tauri CLI)
の通りに実施していただければ大丈夫です
ポイントとなるのは、Next.jsのルートディレクトリに移動して
cargo tauri init
を実行することでしょうか。
あと、開発環境のlocalhostのポート番号が、公式ドキュメントでは5173になっていますが、Next.jsの場合は3000なので、確認してもらえればと思います
3.2.1 注意点(おそらくTauri2.0以降不要)
現在はTauri2.0が出て不要なのですが、このアプリをつくった当初は1.0だったので、.vscode/settings.json
に次の記述をしないと、rust-analyzerがCargo.toml
を見つけられず開発できませんでした。
{
"rust-analyzer.linkedProjects": [
"<Next-app-root>/src-tauri/Cargo.toml"
],
}
<Next-app-root>
にはNext.jsのルートディレクトリ名を書いてください
後は好きに実装するだけですが、いくつかポイントがあるので記述します
3.3. invokeでフロントからRustのコードを実行する
invokeという機能を利用して、WebAPIにfetchする感覚で、Rust側のコードを実行できます
3.3.1. npmパッケージのインストール
bun add @tauri-apps/api
これでtauri-apps/apiをインストールします
3.3.2. フロント側の記述
フロント側は次のような感じで、Rust側で定義しているget_pass
という関数を呼びだせます。WebAPIをfetchするときのコードとほぼ変わらないことがわかります。
'use client'
import { useEffect, useState } from 'react'
import { Card }
import { invoke } from '@tauri-apps/api/core'
const Fetcher = () => {
const [result, setResult] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isCopied, setIsCopied] = useState(false)
const handleFetchResult = async () => {
setIsLoading(true)
setResult(null)
setIsCopied(false)
try {
const res = await invoke<string>('get_pass');
setResult(res)
} catch (error) {
console.error('API呼び出しエラー:', error)
setResult('エラーが発生しました')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
handleFetchResult()
}, [])
return (
<Card className="w-[300px]">
{/* 省略 */}
</Card>
)
}
export default Fetcher
3.3.3. Tauri側の記述
基本はlib.rs
というファイルが生成されているので、ここに記述します。ポイントとなるのは、
- runの中にinvoke_handlerを記述し、関数名も記述
- 実行したい関数の前に
#[tauri::command]
を記述
次のコードは、今回のプロダクトとは別のものですが、ブラウザのログイン処理から必要なデータのwebスクレイピングを行い、フロントに返すものとなっています。先ほどのフロントのコードと合わせることで、アプリ立ち上げ時にこれを実行して、表示することができます
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_pass
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
use headless_chrome::{Browser, LaunchOptions};
use scraper::{Html, Selector};
#[tauri::command]
fn get_pass() -> Result<String, String> {
// ブラウザを起動
let browser = Browser::new(LaunchOptions::default()).map_err(|e| e.to_string())?;
let tab = browser.new_tab().map_err(|e| e.to_string())?;
// ページを開く
tab.navigate_to("https://example.com")
.map_err(|e| e.to_string())?
.wait_until_navigated()
.map_err(|e| e.to_string())?;
// タイトルを取得して表示
let title = tab.get_title().map_err(|e| e.to_string())?;
println!("Title: {}", title);
// UIDフィールドに値を入力
tab
.find_element("#uid").map_err(|e| e.to_string())?
.type_into("pass").map_err(|e| e.to_string())?;
// ボタンをクリック
tab.
find_element("#token > input:nth-child(8)")
.map_err(|e| e.to_string())?
.click()
.map_err(|e| e.to_string())?;
// ページのHTMLを取得
let html: String = tab.get_content().map_err(|e| e.to_string())?;
let document = Html::parse_document(&html);
// セレクターを用意してデータを抽出
let mut input_pass = String::new();
let selectors = vec![
];
for selector_str in selectors {
let selector = Selector::parse(selector_str).map_err(|e| e.to_string())?;
if let Some(element) = document.select(&selector).next() {
input_pass += element.text().collect::<String>().as_str();
}
}
println!("Input Pass: {}", input_pass);
Ok(input_pass)
}
Rustのコード結構えぐいですよね
3.4. Rust側へのアクセスが必要な状態管理
ドラッグ&ドロップ等、webの場合はフロント側で管理しますが、Tauriの場合、Rust側にlistenすることが必要になります。サンプルコードは以下の通り、
import React, { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
interface FileDropZoneProps {
filePath: string | null;
errorMessage: string | null;
}
const FileDropZone: React.FC<FileDropZoneProps> = ({ filePath, errorMessage }) => {
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
const unlistenDropHover = listen('tauri://file-drop-hover', () => {
setIsHovered(true);
console.log('File Hovered');
});
const unlistenDropCancelled = listen('tauri://file-drop-cancelled', () => {
setIsHovered(false);
console.log('File Drop Cancelled');
});
const unlistenDrop = listen('tauri://file-drop', (event) => {
const paths = event.payload as string[];
if (paths && paths.length > 0) {
setIsHovered(false);
console.log('File Dropped:', paths[0]);
}
});
// クリーンアップ
return () => {
unlistenDropHover.then((unlisten) => unlisten());
unlistenDropCancelled.then((unlisten) => unlisten());
unlistenDrop.then((unlisten) => unlisten());
};
}, []);
return (
<div
className={`border-2 border-dashed px-10 py-4 text-center w-full transition-colors ${
isHovered ? 'border-gray-500 bg-gray-100' : 'border-gray-300'
}`}
>
<div className='min-h-24 flex flex-col justify-center items-center'>
{filePath ? (
<p>{filePath}</p>
) : (
<p>ここにファイルをドラッグ&ドロップしてください</p>
)}
{errorMessage && (
<p className="text-red-500 text-center pt-2">{errorMessage}</p>
)}
</div>
</div>
);
};
export default FileDropZone;
ファイルをドラッグ&ドロップして、そのパスを取得するコンポーネントですが、useEffect
内にあるように、Tauri側にアクセスしています。
4. 結果
ドラッグ&ドロップ一発で生成
動画と画像のエクセルが若干違いますが、現場から意見をいただいて、画像のような感じに改変しています。この速度が良いですね。
5. 現在
店舗のPCで使えなかったから、普通に会社のGoogle CloudのCloud RunにWebアプリケーションとしてデプロイしました。
インストールしたその日は問題なく使えたのですが、次の日になぜかエクセルファイルへのアクセスが拒否されるようになってしまったんですよね。
なので、今後は店舗で使うものはCloud Runに、本社で使うものはTauriで実装していこうと思いました。