tasteck
ブログ一覧に戻る
使い方

Build-in-Public Day 20: 来店型業態 Phase 1 を 1 日で完成させた話 — 打ち合わせ→設計→実装→QA

5/13 午後の対面打ち合わせ → 設計議論 → 実装 → migration → QA デプロイ → 動作確認まで 1 セッションで完走した記録。class-transformer @Expose 漏れトラップで 1 commit 追加 fix の現場感、Phase 1.5/1.6 まで含む。

5/13 午後、来店型メンエスのオーナーと対面打ち合わせがあり、tasteck の業態分岐 (来店型 / デリバリー型) Phase 1 を その日のうちに QA 検証まで 完走しました。

本記事は 打ち合わせ → 設計議論 → 実装 → migration → デプロイ → 動作確認 → @Expose 漏れトラップ までの 1 セッションの流れと、後付けで業態分岐を導入する時の判断軸を共有します。

0. 前提

tasteck は元々派遣型 (デリバリー型) ナイトレジャー業界 SaaS として作られていました。最近、来店型メンエス店舗を運営するオーナーから「うちの店でも tasteck 使えるようにならない?」 という相談を受けて、業態分岐を入れることに。

事前に BusinessType enum (delivery / visit) と companies.business_type カラムは migration で追加済 (default delivery)。ただ UI 分岐は一切実装されてない状態。

1. 打ち合わせと設計議論で固めた 6 論点

打ち合わせ後、オーナーと運営者の議論で以下 6 論点を確定。

論点 1: businessType の伝播方法

  • A: auth レスポンスの account.staff.company.companyGroup.business_type で配信 (既存 plan と同じ pattern)
  • B: 新規 hook + Context
  • C: redux top level

A 採用 (既存 pattern 流用)。

論点 2: タブ非表示の条件

配車 / ドライバーシフト タブを既存 StaffRole 条件 (notelMaster / clientMaster 等) と AND するか、業態を一次フィルタにするか。

一次フィルタ採用。「来店型と派遣型は店舗を持つか持たないかぐらい根本的に違う」 (オーナー曰く) なので、ロールに関係なく visit はバッサリ非表示。

const isVisit = businessType === "visit";
if (hasManagerRole) {
  links = [...links, { label: "分析", value: "/analytics" }];
  if (!isVisit) {
    links = [
      ...links,
      { label: "ドライバーシフト", value: "/driverShift" },
      { label: "配車", value: "/driverSchedule" },
    ];
  }
}

「分析」 タブは visit でも必要なので条件式を分割 (visit でも維持)。

論点 3: URL 直アクセスの route guard

visit 企業の人が /driverSchedule を URL 直打ちした時の挙動。

route guard なし。既存企業が visit に切り替える時は別 Phase の「申請式 + 手数料連動」 で対応する方針なので、Phase 1 ではタブだけ消せば十分 (使われない前提)。

論点 4: cast-app / driver-app

  • cast-app: 送迎関連 UI を visit で非表示
  • driver-app: visit 企業のドライバーは存在しない前提で完全放置 (ガードなし)

論点 5: Phase 2 visit 専用機能

オーナーの一言が秀逸でした:

「配車概念というのは、部屋が先に状態としてあるわけです。キャストがシフトを申請したら未アサインに入れることで、それを振り分けることで部屋割りができる」

= 既存配車 UI の構造を「車 → 部屋」 に置換するだけで visit 専用スケジュールができる。配車画面の 2,178 行のコードはほぼ流用可能。

deliveryvisit
行 = ドライバー + 車両行 = 部屋 (固定資産)
緑バー = ドライバー実出勤緑バー = キャスト稼働情報
顧客バッジ顧客バッジ (変わらず)
未アサイン = 配車待ち受注未アサイン = シフト申請済 未割当キャスト

論点 6: 1 companyGroup = 1 業態固定 (混在不可)

  • 同 group 内に delivery + visit 混在を許すか?
  • 不可、DB レベルで保証

companies.business_type を廃止 → company_groups.business_type に一本化 (案 B1)。既存 companies のデータは「グループ内に visit があれば visit、なければ delivery」 で backfill。

2. 実装と @Expose 漏れトラップ

設計議論後、実装着手:

  • server/src/entity/CompanyGroup.tsbusinessType カラム追加
  • server/src/entity/Company.ts から businessType 削除
  • server/src/migrations/<ts>-moveBusinessTypeToCompanyGroups.ts 作成 (3 ステップ: companyGroups にカラム追加 → backfill → companies のカラム削除)
  • server/src/api/public/signup/service.ts の保存先を companies → companyGroups に変更
  • server/src/query/dangerous/CastDangerousQuery.tscompany.companyGroup の join 追加
  • staff-app/src/components/Tabs.tsx に visit 一次フィルタ追加
  • staff-app/src/types/res/companyGroup/CompanyGroupRes.ts に businessType 型追加
  • cast-app/src/types/res/companyGroup/CompanyGroupRes.ts も同様

3 環境とも yarn tsc 通過、commit d115c4f8 で push、QA デプロイ → migration 実行 → PM2 restart まで完走。

トラップ発生 (QA 動作確認時)

QA staff-app で新ナチュラル (visit 切替済) でログインして「配車」 タブが消えない

DevTools で localStorage を覗くと、persisted state の companyGroup には plan は来てるのに businessType フィールドが完全欠落:

"companyGroup": {
  "companyGroupId": 64,
  "name": "タカシ株式会社",
  "plan": "free",
  // businessType が無い ← BUG
}

直接 API を fetch:

const token = JSON.parse(JSON.parse(localStorage.getItem('persist:react-template')).account).accessToken;
fetch('https://qa.api.staff.no-tel.com/v1/api/staff/companyGroup/findOne/64/74', {
  headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.json()).then(console.log);

→ API レスポンスにも businessType フィールド自体無し。

原因: server/src/types/res/companyGroup/CompanyGroupRes.ts@Expose() を付けていなかった:

export default class CompanyGroupRes {
  @Expose() readonly companyGroupId!: number;
  @Expose() readonly name!: string;
  @Expose() readonly plan!: PlanType;  // ← OK
  readonly businessType!: BusinessType; // ← @Expose 漏れ、response から除外
}

class-transformer は excludeExtraneousValues モードで動いてて、@Expose() ない field は plainToClass で完全除外される (null/undefined ではなく field 自体消失)。

修正 commit 6e3000de@Expose() 1 行追加、QA 再デプロイで解決。

学び

「entity に column を追加した」 で安心しない。framework が response 整形に class-transformer + Res クラスを使ってるなら、Res クラスにも対応する @Expose 必須。

checklist 化:

  • server/src/entity/<Entity>.ts (@Column 追加)
  • server/src/types/res/<entity>/<Entity>Res.ts (@Expose 追加) ← 忘れがち
  • フロント側 Res に対応する型追加
  • migration 作成

3. Phase 1.5 — UI 非表示の残り

タブだけじゃ visit 企業のスタッフ/キャストが配車関連 UI を依然見る場面が残ってる。後追いで:

  • cast-app NewOrder: visit 時 DriverSettingBookEmail fetch と driverBookEmailBody 生成を skip
  • cast-app Order/components: スケジュール表の「送り」「迎え」 (送迎ドライバー車情報) を visit 時非表示
  • cast-app InOut/OrderInfo: 入退店情報の「送り」「迎え」 行を visit 時非表示
  • staff-app Order/OrderPrice: 受注詳細の OrderDriver / OrderSendMail ブロックを visit 時非表示
{!isVisit && (
  <>
    <OrderDriver ... />
    <OrderSendMail ... />
  </>
)}

commit 16e32c29 で 4 ファイル / 82 行 / -41 行。

4. Phase 1.6 — super admin 切替の追従

QA 検証中に発覚: notelMaster (super admin) が「ログイン企業切り替え」 で visit 企業 ⇔ delivery 企業を切り替えた時、Tabs が追従しない

原因: Tabs.tsx が state.account.staff.company.companyGroup.businessType を見てたが、これは login 時の固定値。

修正案: state.company (全社一覧) から current staff.companyId の company を引いて、その companyGroup.businessType を見る。

const currentCompanyId = useSelector((state) => state.account.staff.companyId);
const companies = useSelector((state) => state.company);
const loginCompanyBusinessType = useSelector(
  (state) => state.account.staff.company.companyGroup.businessType
);
const currentCompany = companies?.find(c => c.companyId === currentCompanyId);
const businessType = currentCompany?.companyGroup?.businessType ?? loginCompanyBusinessType;
const isVisit = businessType === "visit";

commit 6a4000cd で 1 ファイル / +13 行。

QA 検証: 新ナチュラル (visit) ⇔ 本庄アズール (delivery) を切り替え、タブが動的追従することを Playwright で確認 ✅。

5. QA 動作確認まとめ

シナリオ結果
visit 企業ログイン → 配車/ドライバー系タブ非表示 + 分析タブ維持
delivery regression (本庄アズール) → 全タブ復活
super admin 企業切替: visit ⇔ delivery → タブ動的追従
staff-app 受注詳細: visit 時 OrderDriver/OrderSendMail 非表示
cast-app companyGroup.businessType 取得 + 送迎 UI 非表示
DB migration 適用

6. Phase 2 の見通し

visit 専用機能本体は Phase 2 で実装:

  • rooms テーブル新規 (部屋マスター)
  • casts.cleaning_interval_min カラム追加 (キャスト個別の清掃時間)
  • visit 専用スケジュール画面 (部屋 × 時間 タイムライン、DnD)
  • mouseover ツールチップ (キャストスケジュール + キャスト備考)

既存 driverSchedule 画面の 2,178 行はほぼ流用可能と分析済。react-calendar-timeline ライブラリの groups + items 構造で「車 → 部屋」 「ドライバーシフト → キャストシフト」 のマッピング変更で対応できる見込み。

Phase 2 全体の工数感は 2-3 週間:

  • Phase 2.1 (DB + マスター画面): 3-4 日
  • Phase 2.2 (スケジュール画面骨格): 1 週間
  • Phase 2.3 (DnD ロジック + 清掃時間): 1 週間

7. まとめ — 業態分岐を後付けで入れる時の判断軸 3 つ

  1. DB レベル保証 > ルール依存: 「混在不可」 を運用ルールじゃなく column 位置 (companyGroup 単位) で保証する
  2. 一次フィルタ > AND 条件: 業態分岐は権限ロールと直交させてロジック単純化、既存ロール条件を読み解かなくて済む
  3. entity ≠ response: class-transformer 系では Res.ts@Expose 漏れに注意、entity だけ追加して安心しない

1 日で打ち合わせ → 設計議論 → 実装 → migration → QA まで完走できたのは、AI 駆動開発 (Claude Code Orchestration v3) と「業界特化マイクロ SaaS の業務知識を提供できるオーナー」 の組み合わせがハマった結果。Build-in-Public で言語化していくことで、同じ業界特化 SaaS の運営者にも参考になればと思います。


Build-in-Public は本記事を含め tasteck.tech/blog で公開、AI 自律 PR を実装している方は要 watch。

tasteck — ナイトレジャー業界向け顧客管理 SaaS の Build-in-Public ジャーニー。

30日無料トライアル実施中

現場を、もっと軽やかに。
tasteck で始めましょう。

お申込みは 3 分で完了。カード登録なし、電話番号認証のみ。

  • カード登録なしで開始
  • データ移行サポート無料
  • 全機能 30 日間お試し