MENU

妻のフリマCSV整形が30分→1分に ── Claude AI と Claude Code で作った、やよい白色申告用の変換ツール

妻が数か月前からフリマサイトで日用品を売り始めました。まだ大きな売上ではありませんが、少しずつ増やしていく予定です。将来の確定申告に備えて、いまのうちに帳簿をつけ始めることにし、やよいの白色申告オンラインを使って記録しています。

毎月1回、フリマサイトから売上CSVをダウンロードして、やよいの白色申告オンラインに取り込める形に整える作業が発生します。この整形作業を妻が自分でできるように、私が操作方法を1から教えました。妻はメモをびっしり取り、一人でできるようにもなりました。

ただ、月に1回しかやらない作業なので、細かいところはどうしても忘れます。「あれ、この列ってどうするんだっけ?」が毎月発生し、そのたびに私が説明に入る。嫌ではありません ── 頼られて、力になれているのは純粋に嬉しいことです。ただ、妻の時間も奪うし、私の作業も止まる。この状態は解決できるなら解決したほうがいい。

そこで Claude Code を使って、妻のパソコンで動く HTML 形式の CSV 変換ツールを作りました。結果は、整形作業 30分 → 1分。妻からのコメントは、「せっかく頑張ってメモして覚えたのに!笑。でもすごい簡単になった。ありがとう」でした。

この記事では、このツールを「どう設計して、どう作ったか」を、実際のプロンプト全文と出力されたコードの肝まで見せながら解説します。非エンジニアの方が、自分の業務に似たパターンで移植できる粒度で書きます。

📢 本記事にはアフィリエイトリンク(成果報酬型広告)が含まれます。 詳細は免責事項をご覧ください。
目次

何を作ったか

今回作ったのは、単一のHTMLファイル。ダブルクリックで開くだけで、ブラウザ上で動くツールです。

  • Yahooフリマから落とした売上CSV(Shift-JIS)をドラッグ&ドロップで読み込む
  • やよいの白色申告オンライン「簡単取引入力」にそのまま取り込める形(UTF-8、BOM付き)に変換する
  • 1件の売上から最大3行(売上高/支払手数料/荷造運賃)を自動展開する
  • 日付順・商品ID順でソートして、同じ商品の3行セットは隣り合ったまま並ぶ
  • 変換後CSVを即ダウンロード

ネット接続も、追加のインストールもいりません。妻のパソコンの「CSVを保管するフォルダ」に HTML ファイルを1つ置いてあるだけです。ファイルをダブルクリックするとブラウザが開き、ツールが立ち上がります。

※このツールは我が家の環境専用に作ったものです。対応しているのは Yahooフリマ × やよいの白色申告オンライン「簡単取引入力」 の組み合わせのみで、メルカリや楽天ラクマ、青色申告用フォーマットには対応していません。ただし、後述する仕様書と出力コードは、他の組み合わせへの移植のヒントになります。

なぜ作ったか

月1の作業は、必ず忘れる

妻の CSV 整形作業は、毎月の中旬〜下旬の1回だけ。この頻度で覚え続けるのは、どんなにメモを取っていても難しい。業務で使っている人ならわかると思いますが、月1の作業は毎回「思い出す」時間から始まります

しかも、CSV整形はちょっとしたミスが帳簿全体に響きます。金額の符号を間違えたり、日付のフォーマットを間違えたりすると、やよいの白色申告オンラインに取り込めず、結局手戻りになります。毎月30分かけて、そこそこ神経を使う作業を続けていた状態でした。

妻のPCで動くものにしたかった

最初は「Excel の関数で何とかなるかな」「自分の Mac にスクリプトを書いて使ってもらう形でもいいかな」とも考えました。でも、妻のパソコンで、妻が自分の手でできる形にしたかった。理由はシンプルで、私が不在でも完結できる状態を作りたかったからです。

妻が使っているパソコンは、私が元々使っていた Windows のデスクトップ機。私が MacBook に移行したタイミングで、妻にそのまま引き継ぎました。ここで動いて、追加のインストールが不要で、見た目が分かりやすいもの ── そう考えたときに出てきた答えが、「ブラウザで動く単一HTMLファイル」 でした。

どう作ったか:2段ロケット構造

このツールは、Claude AI(ブラウザ版)で仕様書を作り、それを Claude Code に渡して実装してもらうという2段構えで作りました。文字にするとこういう流れです。

METHOD

2段ロケット方式でAIツールを作る

設計 × 実装 を分離する
1 💬 DESIGN
Claude(ブラウザ版)で仕様書を作る
CSVサンプルを添付し、対話で質問に答えながら仕様を固める
OUTPUT ▸ 仕様書(テキスト)📝
2 ⚙️ BUILD
Claude Codeに仕様書を渡す
仕様書をそのまま貼り付けて「この仕様で作って」と依頼
OUTPUT ▸ 単一HTMLファイル(1ショット)
3 🖱️ RUN
妻のPCに置いて動かす
HTMLをダブルクリックするだけで起動。インストール不要
OUTPUT ▸ ブラウザで動く業務ツール 💻
設計(Claude ブラウザ版) 実装(Claude Code) を分離する。 非エンジニアでも、1ショットで動くツールが作れる。

非エンジニアが Claude Code で少し複雑なツールを作るときに、私がよく使っているのがこのパターンです。いきなり Claude Code に「〇〇を作って」と丸投げすると、期待と違うものが出てきて修正対話に時間がかかります。先に Claude AI と対話して「作りたいものの仕様書」を作りきると、Claude Code 側は実装に集中できて、一発で動くものが返ってくる確率が跳ね上がります。

今回の場合、Claude AI との対話で以下を決めました。

  • 入力 CSV の列構成(Yahooフリマから落としたサンプルを Claude AI に添付して、列を読み取ってもらった)
  • 「取扱日」の出力形式(YYYY/MM/DD、時刻は落とす)
  • 売上に紐づく科目(売上高/支払手数料/荷造運賃)
  • 「−」表示になっている手数料・送料行は出力しないルール
  • ソート順(同一商品ID由来の3行は必ず隣接させる、というやや特殊な仕様)

仕様書が固まった段階で、Claude Code のセッションを開いて、その仕様書をまるごと貼り付けました。返ってきたのは、約660行の単一HTMLファイル1つ。追加の修正対話なしで、妻のPCでそのまま動くものでした。

実際に使った仕様書(プロンプト全文)

Claude AI との対話の末に固まった仕様書、つまり Claude Code に渡したプロンプトの全文です。約1,800字。長いですが、この粒度で書くと Claude Code は迷わず実装してくれるという参考例として、全文公開します。

以下の仕様でWebブラウザ上で動作するシングルページアプリ(HTML + CSS + JavaScript、単一ファイル)を作成してください。

## アプリ概要
Yahooフリマ(ヤフオク)の売上CSVをドラッグ&ドロップで読み込み、
やよいの会計ソフト「簡単取引入力」用のCSVに変換するツール。

---

## 入力CSVの仕様
- エンコード: Shift-JIS
- 1行目はヘッダー
- 列構成(順番通り):
  取扱内容, 商品ID, 取扱日, 状態, 販売, 決済金額, 落札システム利用料, 販売手数料, 送料, 受取金額

- 「取扱日」は `2026年3月22日 10時41分` 形式
- 数値なしの項目は `-` という文字列

---

## UI仕様

1. **年・月の入力欄**(数値入力)
   - ラベル: 「年」「月」
   - デフォルト値: アプリを開いた時点の年・月

2. **CSVドロップエリア**
   - 「CSVファイルをここにドラッグ&ドロップ」と表示
   - クリックでファイル選択ダイアログも開けるようにする

3. **変換・ダウンロードボタン**
   - ファイル読み込み後に「変換してダウンロード」ボタンを表示

4. **プレビューテーブル**
   - 変換後データの先頭10行程度を画面に表示する

---

## 変換ロジック

### 出力CSVの列
取扱内容, 商品ID, 取扱日, 入出金, 科目

### 変換ルール

各入力行から **最大3行** を出力する:

#### ① 決済金額行(必ず出力)
- 取扱内容: 元の「取扱内容」
- 商品ID: 元の「商品ID」
- 取扱日: 元の「取扱日」から日付部分のみ抽出 → `YYYY/MM/DD` 形式
  ※ただし年・月は入力欄の値で上書きする(日のみ元データから取得)
- 入出金: 元の「決済金額」(プラスのまま)
- 科目: 「売上高」

#### ② 販売手数料行(「販売手数料」が `-` でなく数値の場合のみ出力)
- 取扱内容: 元の「取扱内容」
- 商品ID: 元の「商品ID」
- 取扱日: ①と同じ
- 入出金: 元の「販売手数料」にマイナスをつけた値(例: `38` → `-38`)
- 科目: 「支払手数料」

#### ③ 送料行(「送料」が `-` でなく数値の場合のみ出力)
- 取扱内容: 元の「取扱内容」
- 商品ID: 元の「商品ID」
- 取扱日: ①と同じ
- 入出金: 元の「送料」にマイナスをつけた値(例: `160` → `-160`)
- 科目: 「荷造運賃」

---

## 出力CSV仕様
- エンコード: UTF-8(BOM付き、Excelで開けるように)
- 1行目はヘッダー: `取扱内容,商品ID,取扱日,入出金,科目`
- ファイル名: `フリマアプリデータ_YYYY年MM月.csv`(年月は入力欄の値。例: フリマアプリデータ_2026年03月.csv)

## 並び替えルール
出力前に以下の優先順位で昇順ソートを行う:
1. 取扱日(YYYY/MM/DD形式の文字列比較 or Date比較)
2. 商品ID(文字列の昇順)

※同一商品IDから生成された決済金額・販売手数料・送料の3行は、
 ソート後も必ずこの順番を保って隣接するようにすること。
 (ソートは「元の入力行」単位で行い、展開後の3行はまとめて移動させる)

---

## 技術要件
- 単一HTMLファイル(外部CDN不使用)
- Shift-JIS読み込みは `TextDecoder('shift-jis')` を使用(FileReaderでArrayBufferとして読み込む)
- ライブラリなし(Vanilla JS)
- モダンで見やすいUIデザイン

ポイントは、「何を作るか」ではなく「どう動くか」を書いていること。入力はどういう形式で、UI はどう振る舞い、変換ロジックの分岐条件はどうで、出力はどうなる ── この粒度まで落としきると、Claude Code は「実装する人」として動いてくれます。

逆に、この粒度まで自分で落とせない状態で Claude Code を呼ぶのは、早い。まず Claude AI と対話して仕様を詰める、のほうが結果的に速く着地します。

出力されたコードの「肝」3つ

Claude Code が返してきた660行の中には、非エンジニアの私では気づきにくい3つの実装の工夫が入っていました。自分の業務に似たツールを作るときに、頭の片隅に入れておくと役立つポイントです。

① Shift-JIS を正しく読む

Yahooフリマから落とせる CSV は Shift-JIS エンコード。このまま普通にテキストとして読み込むと、日本語が化けます。Claude Code は TextDecoder('shift-jis')FileReader.readAsArrayBuffer() の組み合わせで、化けずに読み込むコードを出してくれました。

// ── File handler: Shift-JIS を ArrayBuffer で読んで TextDecoder に通す
const reader = new FileReader();
reader.onload = function (e) {
  const buffer = e.target.result;
  const decoder = new TextDecoder('shift-jis');
  const text = decoder.decode(buffer);
  parsedRows = parseCSV(text);
  // ...
};
reader.readAsArrayBuffer(file);

仕様書に「TextDecoder('shift-jis') を使う」と明記しておいたので、ここは意図どおりの実装になりました。実装の指定を仕様書レベルで入れておくのは、出力の揺れを減らすコツの1つです。

② 入力1行を、出力最大3行に展開する

1件の売上データから、やよいの白色申告オンラインの仕訳に合わせて「売上高」「支払手数料」「荷造運賃」の最大3行を生成します。手数料や送料が「−」(ハイフン)になっている場合は、その行は生成しない、というルールも含まれます。

// ── 入力1行 → 出力最大3行に展開
const outRows = [];

// ① 決済金額(必ず出力)
outRows.push({
  取扱内容: row.取扱内容,
  商品ID:   row.商品ID,
  取扱日:   dateStr,
  入出金:   String(kingaku),
  科目:     '売上高',
});

// ② 販売手数料(「-」でなければ、マイナス符号をつけて出力)
if (row.販売手数料 !== '-' && row.販売手数料 !== '') {
  const fee = parseNumericValue(row.販売手数料);
  if (fee !== null) {
    outRows.push({
      ...
      入出金: String(-Math.abs(fee)),
      科目: '支払手数料',
    });
  }
}

// ③ 送料(同上、科目は「荷造運賃」)

符号の向きにも注意が入っています。販売手数料と送料は必ずマイナス(支出)として出力される必要があるため、String(-Math.abs(fee)) のように、元の値の符号に依存せず強制的にマイナスにする書き方になっています。これは仕様書では「マイナスをつけた値」と指示しただけでしたが、Claude Code が「入力データ側の表記ゆれに強い書き方」で実装してくれました。

③ ソート時に3行セットを崩さない

これが今回いちばん面白かった部分です。出力は「日付→商品ID」の順にソートしたい。でも、売上1件から出る3行セット(売上高・支払手数料・荷造運賃)は、ソート後も必ず隣接して並んでいてほしい。普通にフラットに並べてソートすると、手数料行が別の売上の間に紛れ込む可能性がある。

Claude Code が出してきた解答は、ソートを「3行セット単位」で行うという書き方でした。

// ── ソート時に3行セットを崩さない
// 各入力行を「sortKey + 3行セット」として扱ってからソートする
const groups = inputRows.map(row => {
  const outRows = buildThreeRows(row); // 1〜3行
  return { sortKey: dateStr + '\x00' + row.商品ID, outRows };
});

// グループ単位でソート(同一商品IDの3行は必ず隣接したまま動く)
groups.sort((a, b) => a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0);

// 展開してフラット化
return groups.flatMap(g => g.outRows);

入力行ごとに「sortKey(日付+商品IDで作る並び順のキー)+出力する1〜3行のセット」をまとめてグループ化し、グループ単位でソートしてから、最後に flatMap で平らに展開する。こうすると、3行セットは絶対に崩れません。

仕様書側で「ソートは元の入力行単位で行い、展開後の3行はまとめて移動させる」と書いておいたのが効きました。「あとで困ることを仕様書に書く」のは、非エンジニアが自分の業務を仕様に落とすときに意外と大事なポイントです。

妻が使ってみてどうだったか

せっかく頑張ってメモして覚えたのに!笑。でもすごい簡単になった。ありがとう。

(妻のコメント)

メモして覚えた作業を一瞬でツール化された妻の、本音の混ざった一言でした。笑いながら言ってくれたので、正しい方向だったと受け取っています。

数字で見ると、CSV整形にかかっていた時間はこう変わりました。

工程ツール導入前ツール導入後
CSV整形作業約30分/月約1分/月
私に確認する時間毎月発生ほぼゼロ
ミスのリカバリ年に数回いまのところゼロ

月に29分の短縮。年間で見ると約6時間。金額に直せば些末な数字ですが、副業で動いているうちは「時間を生み出せたこと」そのものに価値があると感じています。作業を「確実に・短く・自力で」終えられる感覚は、副業のモチベーションに直結します。

自分の業務に移植するヒント

読んでくださっている方の業務にそのまま使える可能性は低いかもしれません(Yahooフリマ × やよいの白色申告オンラインの組み合わせは特殊なので)。ただ、構造としては、かなり応用が利くパターンです。

  • 「AシステムからダウンロードしたCSV/エクスポート」を「Bシステムに取り込める形」に変換する ── 給与システム→会計、予約システム→CRM、EC→在庫管理など、業務の至るところに同型の作業があります。
  • 月1・週1・四半期1 の作業ほど、ツール化の価値が高い ── 毎日やる作業は覚えていますが、たまの作業は毎回思い出すコストが発生します。頻度が低いほど恩恵は大きい。
  • 単一HTMLファイルは「非エンジニアの家族・スタッフに配布できる最強フォーマット」 ── ダブルクリックで動き、インストール不要、ネット不要。USBメモリでもメール添付でも共有できる。
  • 仕様書は Claude AI、実装は Claude Code ── 「何を作るかを固める対話」と「固まったものを実装する作業」は別モードです。非エンジニアほど、この2段構えを意識すると時間効率が跳ね上がります。

「自分の業務で何か思いつかない」という方は、身近な人が毎月やっている作業を観察してみるのがおすすめです。「覚えにくい・教えるのに時間がかかる・同じ質問が来る」──この3つが揃っていたら、ツール化の対象として有望です。

まとめ

  • 妻のフリマ副業の帳簿付けを支えるために、CSV整形ツールを Claude Code で作った
  • ツールは単一HTMLファイル。ダブルクリックで妻のPCで動く
  • 作り方は「Claude AI で仕様書を作り、Claude Code に実装させる」の2段ロケット
  • 追加の修正対話なしで一発完成。整形作業は30分→1分に短縮
  • このパターンは、月1の定型作業を抱えている個人事業主・中小企業経営者の業務効率化に広く応用できる

帳簿付けを始めようとしている副業の方、家族・スタッフが月1の定型作業で詰まっている経営者の方、参考になるところがあれば幸いです。


関連リンク【PR】

やよいの白色申告オンライン

今回のツールの出力先として使っているクラウド白色申告ソフトです。個人事業主・副業の方なら無料プランから始められます。CSV取り込み機能(「簡単取引入力」)があり、フォーマットさえ整えればそのまま仕訳として登録できます。

※ 下記リンクは成果報酬型広告です。遷移先はやよいの白色申告オンライン公式サイトです。

👉 やよいの白色申告オンライン 公式サイトを見る

Claude Code 関連記事

このツールを作ったのは Claude Code Max プラン(月額$100)の契約範囲内。実際に月$100払う価値があるかを、4週間の生ログで検証した記事はこちらです。

👉 Claude Code の料金は月$100の価値があるか ── Max課金4週間の生ログと判断軸


付録:ツールのHTMLコード全文

「そのまま使ってみたい」「自分のツールを作るときの参考にしたい」という方のために、HTMLコードの全文を折りたたみで掲載します。Yahooフリマ × やよいの白色申告オンライン「簡単取引入力」の組み合わせに特化しているため、他の組み合わせではそのままでは動きません。参考実装としてご覧ください。

▼ HTMLコード全文を表示する(約660行)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>フリマ売上CSV変換ツール(やよい会計用)</title>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
    background: #f0f4f8;
    color: #1a202c;
    min-height: 100vh;
    padding: 32px 16px;
  }

  .container {
    max-width: 860px;
    margin: 0 auto;
  }

  h1 {
    font-size: 1.5rem;
    font-weight: 700;
    color: #2d3748;
    margin-bottom: 4px;
  }

  .subtitle {
    font-size: 0.875rem;
    color: #718096;
    margin-bottom: 28px;
  }

  .card {
    background: #fff;
    border-radius: 12px;
    box-shadow: 0 1px 4px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.05);
    padding: 24px;
    margin-bottom: 20px;
  }

  .card-title {
    font-size: 0.8rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: #a0aec0;
    margin-bottom: 14px;
  }

  /* Year/Month inputs */
  .date-row {
    display: flex;
    align-items: center;
    gap: 16px;
    flex-wrap: wrap;
  }

  .date-field {
    display: flex;
    align-items: center;
    gap: 8px;
  }

  .date-field label {
    font-size: 0.9rem;
    font-weight: 500;
    color: #4a5568;
    white-space: nowrap;
  }

  .date-field input[type="number"] {
    width: 80px;
    padding: 8px 10px;
    border: 1.5px solid #e2e8f0;
    border-radius: 8px;
    font-size: 1rem;
    color: #2d3748;
    outline: none;
    transition: border-color 0.15s;
    text-align: center;
  }

  .date-field input[type="number"]:focus {
    border-color: #667eea;
    box-shadow: 0 0 0 3px rgba(102,126,234,0.15);
  }

  /* Drop zone */
  .drop-zone {
    border: 2px dashed #cbd5e0;
    border-radius: 10px;
    padding: 40px 20px;
    text-align: center;
    cursor: pointer;
    transition: border-color 0.2s, background 0.2s;
    position: relative;
  }

  .drop-zone:hover, .drop-zone.dragover {
    border-color: #667eea;
    background: #ebf4ff;
  }

  .drop-zone input[type="file"] {
    position: absolute;
    inset: 0;
    opacity: 0;
    cursor: pointer;
    width: 100%;
    height: 100%;
  }

  .drop-icon {
    font-size: 2.5rem;
    margin-bottom: 10px;
    display: block;
  }

  .drop-label {
    font-size: 0.95rem;
    color: #4a5568;
    font-weight: 500;
  }

  .drop-sub {
    font-size: 0.8rem;
    color: #a0aec0;
    margin-top: 4px;
  }

  .file-name {
    margin-top: 12px;
    font-size: 0.875rem;
    color: #48bb78;
    font-weight: 500;
  }

  /* Button */
  .btn-convert {
    display: none;
    margin-top: 16px;
    width: 100%;
    padding: 13px;
    background: linear-gradient(135deg, #667eea, #764ba2);
    color: #fff;
    border: none;
    border-radius: 10px;
    font-size: 1rem;
    font-weight: 600;
    cursor: pointer;
    transition: opacity 0.15s, transform 0.1s;
    letter-spacing: 0.02em;
  }

  .btn-convert:hover { opacity: 0.92; }
  .btn-convert:active { transform: scale(0.98); }

  /* Error */
  .error-box {
    display: none;
    background: #fff5f5;
    border: 1.5px solid #fed7d7;
    border-radius: 8px;
    color: #c53030;
    padding: 12px 16px;
    font-size: 0.875rem;
    margin-top: 12px;
  }

  /* Stats */
  .stats {
    display: none;
    margin-top: 14px;
    padding: 12px 16px;
    background: #f0fff4;
    border: 1.5px solid #c6f6d5;
    border-radius: 8px;
    font-size: 0.875rem;
    color: #276749;
    font-weight: 500;
  }

  /* Preview */
  .preview-card { display: none; }

  .preview-scroll {
    overflow-x: auto;
    border-radius: 8px;
    border: 1px solid #e2e8f0;
  }

  table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.82rem;
    white-space: nowrap;
  }

  thead tr {
    background: #f7fafc;
  }

  th {
    padding: 10px 14px;
    text-align: left;
    font-weight: 600;
    color: #4a5568;
    border-bottom: 2px solid #e2e8f0;
  }

  td {
    padding: 8px 14px;
    border-bottom: 1px solid #edf2f7;
    color: #2d3748;
  }

  tbody tr:hover { background: #f7fafc; }

  .tag {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 999px;
    font-size: 0.72rem;
    font-weight: 600;
  }

  .tag-sell { background: #ebf8ff; color: #2b6cb0; }
  .tag-fee  { background: #fff5f5; color: #c53030; }
  .tag-ship { background: #fffaf0; color: #c05621; }

  .more-rows {
    text-align: center;
    padding: 12px;
    font-size: 0.82rem;
    color: #a0aec0;
    border-top: 1px solid #edf2f7;
  }

  .amount-neg { color: #e53e3e; }
  .amount-pos { color: #276749; }
</style>
</head>
<body>
<div class="container">
  <h1>フリマ売上CSV変換ツール</h1>
  <p class="subtitle">Yahooフリマ(ヤフオク)の売上CSVをやよいの会計「簡単取引入力」用に変換します</p>

  <!-- Step 1: Date settings -->
  <div class="card">
    <div class="card-title">対象年月</div>
    <div class="date-row">
      <div class="date-field">
        <label for="inputYear">年</label>
        <input type="number" id="inputYear" min="2000" max="2099" step="1">
      </div>
      <div class="date-field">
        <label for="inputMonth">月</label>
        <input type="number" id="inputMonth" min="1" max="12" step="1">
      </div>
    </div>
  </div>

  <!-- Step 2: File drop -->
  <div class="card">
    <div class="card-title">CSVファイル(Shift-JIS)</div>
    <div class="drop-zone" id="dropZone">
      <input type="file" id="fileInput" accept=".csv">
      <span class="drop-icon">📂</span>
      <div class="drop-label">CSVファイルをここにドラッグ&ドロップ</div>
      <div class="drop-sub">またはクリックしてファイルを選択</div>
      <div class="file-name" id="fileName"></div>
    </div>
    <div class="error-box" id="errorBox"></div>
    <div class="stats" id="statsBox"></div>
    <button class="btn-convert" id="btnConvert">変換してダウンロード ↓</button>
  </div>

  <!-- Step 3: Preview -->
  <div class="card preview-card" id="previewCard">
    <div class="card-title">プレビュー(先頭10行)</div>
    <div class="preview-scroll">
      <table id="previewTable">
        <thead>
          <tr>
            <th>取扱内容</th>
            <th>商品ID</th>
            <th>取扱日</th>
            <th>入出金</th>
            <th>科目</th>
          </tr>
        </thead>
        <tbody id="previewBody"></tbody>
      </table>
      <div class="more-rows" id="moreRows" style="display:none"></div>
    </div>
  </div>
</div>

<script>
(function () {
  'use strict';

  // ── Init year/month ──────────────────────────────────────────────
  const now = new Date();
  const yearInput  = document.getElementById('inputYear');
  const monthInput = document.getElementById('inputMonth');
  yearInput.value  = now.getFullYear();
  monthInput.value = now.getMonth() + 1;

  // ── State ────────────────────────────────────────────────────────
  let parsedRows = null; // raw parsed input rows (array of objects)

  // ── DOM refs ─────────────────────────────────────────────────────
  const dropZone   = document.getElementById('dropZone');
  const fileInput  = document.getElementById('fileInput');
  const fileName   = document.getElementById('fileName');
  const errorBox   = document.getElementById('errorBox');
  const statsBox   = document.getElementById('statsBox');
  const btnConvert = document.getElementById('btnConvert');
  const previewCard= document.getElementById('previewCard');
  const previewBody= document.getElementById('previewBody');
  const moreRows   = document.getElementById('moreRows');

  // ── Drag & Drop ──────────────────────────────────────────────────
  dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
  dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
  dropZone.addEventListener('drop', e => {
    e.preventDefault();
    dropZone.classList.remove('dragover');
    const file = e.dataTransfer.files[0];
    if (file) handleFile(file);
  });
  fileInput.addEventListener('change', () => {
    if (fileInput.files[0]) handleFile(fileInput.files[0]);
  });

  btnConvert.addEventListener('click', doConvertAndDownload);

  // ── File handler ─────────────────────────────────────────────────
  function handleFile(file) {
    showError('');
    parsedRows = null;
    statsBox.style.display = 'none';
    btnConvert.style.display = 'none';
    previewCard.style.display = 'none';

    if (!file.name.toLowerCase().endsWith('.csv')) {
      showError('CSVファイル(.csv)を選択してください。');
      return;
    }

    fileName.textContent = '📄 ' + file.name;

    const reader = new FileReader();
    reader.onload = function (e) {
      try {
        const buffer = e.target.result;
        const decoder = new TextDecoder('shift-jis');
        const text = decoder.decode(buffer);
        parsedRows = parseCSV(text);
        if (parsedRows.length === 0) {
          showError('データ行が見つかりませんでした。');
          return;
        }
        statsBox.textContent = `✅ ${parsedRows.length} 件読み込みました`;
        statsBox.style.display = 'block';
        btnConvert.style.display = 'block';
        renderPreview(buildOutputRows(parsedRows));
      } catch (err) {
        showError('ファイルの読み込みに失敗しました: ' + err.message);
      }
    };
    reader.onerror = () => showError('ファイルの読み込み中にエラーが発生しました。');
    reader.readAsArrayBuffer(file);
  }

  // ── CSV parser (simple, handles quoted fields) ───────────────────
  function parseCSV(text) {
    // Normalize line endings
    const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
    if (lines.length < 2) return [];

    const headers = splitCSVLine(lines[0]);
    // Expected columns
    const COL = {
      取扱内容:         headers.indexOf('取扱内容'),
      商品ID:           headers.indexOf('商品ID'),
      取扱日:           headers.indexOf('取扱日'),
      状態:             headers.indexOf('状態'),
      販売:             headers.indexOf('販売'),
      決済金額:         headers.indexOf('決済金額'),
      落札システム利用料: headers.indexOf('落札システム利用料'),
      販売手数料:       headers.indexOf('販売手数料'),
      送料:             headers.indexOf('送料'),
      受取金額:         headers.indexOf('受取金額'),
    };

    // Validate required columns
    const required = ['取扱内容','商品ID','取扱日','決済金額','販売手数料','送料'];
    for (const col of required) {
      if (COL[col] === -1) throw new Error(`列「${col}」が見つかりません。CSVのフォーマットを確認してください。`);
    }

    const rows = [];
    for (let i = 1; i < lines.length; i++) {
      const line = lines[i].trim();
      if (!line) continue;
      const fields = splitCSVLine(line);
      rows.push({
        取扱内容:   getField(fields, COL.取扱内容),
        商品ID:     getField(fields, COL.商品ID),
        取扱日:     getField(fields, COL.取扱日),
        決済金額:   getField(fields, COL.決済金額),
        販売手数料: getField(fields, COL.販売手数料),
        送料:       getField(fields, COL.送料),
      });
    }
    return rows;
  }

  function getField(fields, idx) {
    if (idx < 0 || idx >= fields.length) return '';
    return fields[idx];
  }

  function splitCSVLine(line) {
    const result = [];
    let cur = '';
    let inQuote = false;
    for (let i = 0; i < line.length; i++) {
      const ch = line[i];
      if (inQuote) {
        if (ch === '"') {
          if (line[i + 1] === '"') { cur += '"'; i++; }
          else inQuote = false;
        } else {
          cur += ch;
        }
      } else {
        if (ch === '"') { inQuote = true; }
        else if (ch === ',') { result.push(cur); cur = ''; }
        else { cur += ch; }
      }
    }
    result.push(cur);
    return result;
  }

  // ── Date parsing ─────────────────────────────────────────────────
  // Input: "2026年3月22日 10時41分"  →  day=22
  function parseDayFromJapanese(str) {
    const m = str.match(/(\d+)日/);
    return m ? parseInt(m[1], 10) : null;
  }

  function buildDateString(year, month, day) {
    const y = String(year).padStart(4, '0');
    const m = String(month).padStart(2, '0');
    const d = String(day).padStart(2, '0');
    return `${y}/${m}/${d}`;
  }

  // ── Build output rows (grouped by input row, sorted) ─────────────
  function buildOutputRows(inputRows) {
    const year  = parseInt(yearInput.value, 10);
    const month = parseInt(monthInput.value, 10);

    // Each group = [inputRow, outputRows[]]
    const groups = inputRows.map(row => {
      const day = parseDayFromJapanese(row.取扱日);
      const dateStr = (day !== null) ? buildDateString(year, month, day) : '';

      const outRows = [];

      // ① 決済金額
      const kingaku = parseNumericValue(row.決済金額);
      outRows.push({
        取扱内容: row.取扱内容,
        商品ID:   row.商品ID,
        取扱日:   dateStr,
        入出金:   kingaku !== null ? String(kingaku) : row.決済金額,
        科目:     '売上高',
      });

      // ② 販売手数料
      if (row.販売手数料 !== '-' && row.販売手数料 !== '') {
        const fee = parseNumericValue(row.販売手数料);
        if (fee !== null) {
          outRows.push({
            取扱内容: row.取扱内容,
            商品ID:   row.商品ID,
            取扱日:   dateStr,
            入出金:   String(-Math.abs(fee)),
            科目:     '支払手数料',
          });
        }
      }

      // ③ 送料
      if (row.送料 !== '-' && row.送料 !== '') {
        const ship = parseNumericValue(row.送料);
        if (ship !== null) {
          outRows.push({
            取扱内容: row.取扱内容,
            商品ID:   row.商品ID,
            取扱日:   dateStr,
            入出金:   String(-Math.abs(ship)),
            科目:     '荷造運賃',
          });
        }
      }

      return { sortKey: dateStr + '\x00' + row.商品ID, outRows };
    });

    // Sort groups by (取扱日, 商品ID) keeping 3 rows together
    groups.sort((a, b) => a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0);

    // Flatten
    return groups.flatMap(g => g.outRows);
  }

  function parseNumericValue(str) {
    if (!str || str === '-') return null;
    // Remove commas and spaces
    const cleaned = str.replace(/,/g, '').trim();
    const n = Number(cleaned);
    return isNaN(n) ? null : n;
  }

  // ── Preview ──────────────────────────────────────────────────────
  function renderPreview(rows) {
    previewBody.innerHTML = '';
    const PREVIEW_LIMIT = 10;
    const showRows = rows.slice(0, PREVIEW_LIMIT);

    showRows.forEach(row => {
      const tr = document.createElement('tr');
      const amtNum = Number(row.入出金);
      const amtClass = amtNum < 0 ? 'amount-neg' : 'amount-pos';
      const sciTag = scienceTag(row.科目);

      tr.innerHTML = `
        <td>${esc(row.取扱内容)}</td>
        <td>${esc(row.商品ID)}</td>
        <td>${esc(row.取扱日)}</td>
        <td class="${amtClass}">${formatAmount(row.入出金)}</td>
        <td>${sciTag}</td>
      `;
      previewBody.appendChild(tr);
    });

    if (rows.length > PREVIEW_LIMIT) {
      moreRows.style.display = 'block';
      moreRows.textContent = `… 他 ${rows.length - PREVIEW_LIMIT} 行(合計 ${rows.length} 行)`;
    } else {
      moreRows.style.display = 'none';
    }

    previewCard.style.display = 'block';
  }

  function scienceTag(name) {
    const map = { '売上高': 'tag-sell', '支払手数料': 'tag-fee', '荷造運賃': 'tag-ship' };
    const cls = map[name] || '';
    return `<span class="tag ${cls}">${esc(name)}</span>`;
  }

  function formatAmount(val) {
    const n = Number(val);
    if (isNaN(n)) return esc(val);
    return n.toLocaleString('ja-JP');
  }

  function esc(s) {
    return String(s)
      .replace(/&/g,'&amp;')
      .replace(/</g,'&lt;')
      .replace(/>/g,'&gt;')
      .replace(/"/g,'&quot;');
  }

  // ── Convert & Download ───────────────────────────────────────────
  function doConvertAndDownload() {
    if (!parsedRows) return;
    showError('');

    try {
      const year  = parseInt(yearInput.value, 10);
      const month = parseInt(monthInput.value, 10);

      if (!year || !month || month < 1 || month > 12) {
        showError('正しい年・月を入力してください。');
        return;
      }

      const rows = buildOutputRows(parsedRows);
      renderPreview(rows);

      const csvContent = buildCSVContent(rows);

      // UTF-8 BOM
      const BOM = '\uFEFF';
      const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });

      const mm = String(month).padStart(2, '0');
      const filename = `フリマアプリデータ_${year}年${mm}月.csv`;

      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      setTimeout(() => URL.revokeObjectURL(url), 1000);
    } catch (err) {
      showError('変換中にエラーが発生しました: ' + err.message);
    }
  }

  function buildCSVContent(rows) {
    const header = ['取扱内容', '商品ID', '取扱日', '入出金', '科目'];
    const lines = [header.map(csvCell).join(',')];
    for (const row of rows) {
      lines.push([
        csvCell(row.取扱内容),
        csvCell(row.商品ID),
        csvCell(row.取扱日),
        csvCell(row.入出金),
        csvCell(row.科目),
      ].join(','));
    }
    return lines.join('\r\n');
  }

  function csvCell(val) {
    const s = String(val ?? '');
    if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) {
      return '"' + s.replace(/"/g, '""') + '"';
    }
    return s;
  }

  // ── Error helper ─────────────────────────────────────────────────
  function showError(msg) {
    if (msg) {
      errorBox.textContent = '⚠️ ' + msg;
      errorBox.style.display = 'block';
    } else {
      errorBox.style.display = 'none';
    }
  }

})();
</script>
</body>
</html>

使う場合は、テキストエディタにコピペして freemarket-to-yayoi.html のような名前で保存し、ダブルクリックで開くだけです。入力CSVの形式が違う場合は、Claude AI に「この記事のコードをベースに、以下の入力CSV形式に対応させたい」と相談すると、差分の修正点を教えてくれます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

東証プライム上場企業の中間管理職。プログラミング経験ゼロから Claude Code で業務効率化ツールを自作中。「非エンジニアが本当に作れるのか」の実験記録を一次情報で発信しています。

コメント

コメントする

CAPTCHA


目次