← Files BeautyLinks 店舗サポートARCHIVED FILE

skills/beautylinks-support/references/code-excerpts.md

37.7 KB · Oct 3, 2026 · 06:36 UTC

↓ Download file

# 固定版のコード断片

確認日:2026-09-20。以下は操作仕様の説明に必要な原文の抜粋。認証情報や実データを含まない。完全な関数とは限らないため、抜粋外の挙動まで推測しない。回答の手順は対応するコード照合メモと操作説明書を併読する。

## 目次

- R1:電話番号は任意・画面側の形式確認
- R2:公開中スタッフ1人なら担当欄を省略
- R3:カレンダー表示方式で変わる入力
- R4:新規予約の確認と登録ボタン
- R5:電話番号の保存側の形式確認
- R6:新規顧客はLINE未連携・未同意として作成
- A1:管理画面用:特別営業時間を先に適用
- A2:別の営業時間関数:休業日を先に適用
- A3:予約枠が不成立となる理由
- A4:管理画面のセット所要時間の優先順
- P1:誕生日手動配信の実行引数
- P2:誕生日配信対象の絞り込み
- P3:手動配信は年次重複防止とは別の発行
- P4:紹介された方の初回来店済みで判定
- T1:発送登録から送る内容
- T2:現行発送フォームは発送メモ
- T3:画面の返金は残りの対象額すべて
- T4:一括発送準備の対象と結果
- T5:原価などが不明なら利益は未算出
- X1:閲覧者の書き込みはサーバー側で拒否
- X2:売上向上はサイドバーで追加条件がある
- X3:契約の利用権と猶予期間

ファイルパスと行番号は下記の固定コミットに対するもの。別の版へ行番号だけを流用しない。GitHubへのリンクは閲覧権限がある利用者向け。店舗利用者は同梱の抜粋をそのまま読める。

## R1:電話番号は任意・画面側の形式確認

出典:`kirato0905-cmyk/BeautyLinks_admin_web` / `app/admin/reservations/new/page.tsx`
確認コミット:`6d6e049850867421c7c41f80190d64123ef1ff0b`

[元コード L313〜L322](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/app/admin/reservations/new/page.tsx#L313-L322)

```tsx
function validateJapanesePhoneNumber(value: string): string | null {
  const cleaned = String(value || "").replace(/[\s-]/g, "");
  // 管理画面からの予約作成では電話番号は任意。
  // 入力された場合だけ形式チェックする。
  if (!cleaned) return null;
  if (!/^(0\d{9,10}|\+81\d{9,10})$/.test(cleaned)) {
    return "電話番号は日本の電話番号形式で入力してください。例:09012345678";
  }
  return null;
}
```

## R2:公開中スタッフ1人なら担当欄を省略

出典:`kirato0905-cmyk/BeautyLinks_admin_web` / `app/admin/reservations/new/page.tsx`
確認コミット:`6d6e049850867421c7c41f80190d64123ef1ff0b`

[元コード L463〜L475](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/app/admin/reservations/new/page.tsx#L463-L475)

```tsx
  const activeStaff = useMemo(
    () => staff.filter((member) => member.is_active),
    [staff],
  );
  const autoAssignedStaff =
    activeStaff.length === 1 ? activeStaff[0] : null;

  // 公開中スタッフが1人だけなら担当者は自動確定。
  // 担当選択・担当名・指名料のUIは表示しない。
  const shouldHideStaffUi = activeStaff.length === 1;
  const staffIdIsActive = activeStaff.some(
    (member) => member.id === staffId,
  );
```

## R3:カレンダー表示方式で変わる入力

出典:`kirato0905-cmyk/BeautyLinks_admin_web` / `app/admin/reservations/new/page.tsx`
確認コミット:`6d6e049850867421c7c41f80190d64123ef1ff0b`

[元コード L1640〜L1658](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/app/admin/reservations/new/page.tsx#L1640-L1658)

```tsx
                    {date} {calendarPresetHasStartTime ? startTime : "開始時間は未選択"}
                    {calendarPresetHasStartTime ? ` 〜 ${previewDuration ? previewEndTime : "メニュー選択後に表示"}` : ""}
                  </strong>
                  <p>{calendarPresetHasStartTime
                    ? "日・週カレンダーの枠から作成しています。この場合、空き時間候補は表示しません。登録時にDB上の空き状況を最終確認します。"
                    : "月カレンダーから作成しています。日付だけ引き継ぎ、開始時間は手動で選択してください。"}</p>
                </div>
              ) : null}

              {pastDateBlocked ? (
                <p className="helperText warningText">過去の日付では新規予約を作成できません。</p>
              ) : dateIsPast && ALLOW_PAST_ADMIN_RESERVATIONS ? (
                <p className="helperText warningText">確認用テストモード:過去日予約を登録できます。登録後は「来店済み」に変更してください。</p>
              ) : shouldHideTimeCandidates ? (
                <p className="helperText">日・週カレンダーの枠から作成しているため、空き時間候補は表示しません。</p>
              ) : !staffId || serviceIds.length === 0 ? (
                <p className="helperText">{shouldHideStaffUi ? "予約日・メニューを選ぶと、空き時間候補が表示されます。" : "予約日・担当スタッフ・メニューを選ぶと、空き時間候補が表示されます。"}</p>
              ) : calendarLoading || durationLoading ? (
                <p className="helperText">空き時間と顧客別所要時間を取得しています...</p>
```

## R4:新規予約の確認と登録ボタン

出典:`kirato0905-cmyk/BeautyLinks_admin_web` / `app/admin/reservations/new/page.tsx`
確認コミット:`6d6e049850867421c7c41f80190d64123ef1ff0b`

[元コード L1256〜L1270](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/app/admin/reservations/new/page.tsx#L1256-L1270)

```tsx
    const confirmed = await confirm({
      title: "操作を確認してください",
      description: `新規予約を登録しますか?\n\n` +
      `顧客:${trimmedName} / ${trimmedPhone || "電話番号未登録"}\n` +
      `日時:${date} ${startTime} - ${previewEndTime}\n` +
      staffConfirmLine +
      `メニュー:${selectedFeaturedSet?.name || chosenServices.map((service) => service.name).join(" / ")}\n` +
      `${priceLines}\n` +
      `所要時間:${previewDuration}分\n\n` +
      (dateIsPast && ALLOW_PAST_ADMIN_RESERVATIONS
        ? `【確認用】過去日予約として登録します。登録後に予約詳細で「来店済み」へ変更してください。`
        : `管理画面のカレンダー・予約一覧・通知に料金内訳まで反映されます。`),
      confirmLabel: "実行する",
      danger: false,
    });
```

[元コード L1839〜L1844](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/app/admin/reservations/new/page.tsx#L1839-L1844)

```tsx
              <button className="submitButton" type="button" disabled={creating || durationLoading || pricingLoading || pastDateBlocked || !startTimeIsAvailable} onClick={() => void createReservation()}
  aria-busy={creating || durationLoading || pricingLoading}>
                {creating ? "登録中..." : durationLoading ? "所要時間確認中..." : pricingLoading ? "料金確認中..." : "新規予約を登録"}
              </button>
              <p className="noticeText">登録時にDB上の空き状況を最終確認し、管理画面カレンダーに反映します。</p>
            </aside>
```

## R5:電話番号の保存側の形式確認

出典:`kirato0905-cmyk/beauty-links-backend` / `api/admin/routes.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L8056〜L8076](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/admin/routes.py#L8056-L8076)

```python
def _normalize_admin_reservation_phone(phone_number: Optional[str]) -> str:
    """管理画面入力の電話番号を数字のみへ正規化する。

    管理画面からの予約作成では電話番号は任意。
    未入力の場合は空文字で保存し、入力された場合だけ形式チェックする。
    """
    text = str(phone_number or "").strip().replace("−", "-").replace("ー", "-").replace("―", "-")
    if not text:
        return ""
    if not re.fullmatch(r"[0-9\-]+", text):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail={"code": "INVALID_PHONE_NUMBER", "message": "電話番号は数字またはハイフンで入力してください。"},
        )
    digits = text.replace("-", "")
    if len(digits) not in {10, 11}:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail={"code": "INVALID_PHONE_NUMBER", "message": "電話番号は10桁または11桁で入力してください。"},
        )
    return digits
```

## R6:新規顧客はLINE未連携・未同意として作成

出典:`kirato0905-cmyk/beauty-links-backend` / `api/admin/routes.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L7673〜L7713](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/admin/routes.py#L7673-L7713)

```python
def _ensure_admin_reservation_customer_for_create(
    db: Session,
    admin: AdminContext,
    existing_customer: Optional[Customer],
    *,
    client_name: str,
    phone_number: str,
) -> Customer:
    """管理画面の新規予約保存時に、顧客一覧へ出す顧客レコードを保証する。

    既存顧客が見つかっている場合は氏名/電話番号を同期し、
    見つからない場合はLINE未連携の顧客として新規作成する。
    """
    if existing_customer:
        _sync_admin_reservation_customer_fields(
            existing_customer,
            client_name=client_name,
            phone_number=phone_number,
        )
        return existing_customer

    name = str(client_name or "").strip()
    customer = Customer(
        store_id=admin.store_id,
        line_user_id=_make_admin_manual_line_user_id(db, admin.store_id),
        display_name=name,
        full_name=name,
        phone_number=str(phone_number or "").strip() or None,
        status="active",
        consented=False,
        input_state=None,
    )
    db.add(customer)
    db.flush()
    return customer


def _modification_snapshot(reservation: Reservation) -> dict:
    return {
        "status": reservation.status,
        "date": reservation.date.isoformat(),
```

## A1:管理画面用:特別営業時間を先に適用

出典:`kirato0905-cmyk/beauty-links-backend` / `api/services/availability_service.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L308〜L357](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/services/availability_service.py#L308-L357)

```python
    def get_store_periods(self, target_date: Any) -> List[Dict[str, str]]:
        resolved_date = self.parse_date(target_date)
        cache_key = resolved_date.isoformat()
        if cache_key in self._store_periods_cache:
            return [dict(period) for period in self._store_periods_cache[cache_key]]

        if resolved_date not in self._special_hours_cache:
            self._special_hours_cache[resolved_date] = (
                self.db.query(StoreSpecialHour)
                .filter(
                    StoreSpecialHour.store_id == self.store_id,
                    StoreSpecialHour.special_date == resolved_date,
                )
                .first()
            )
        special = self._special_hours_cache[resolved_date]
        if special:
            if special.is_closed:
                self._store_periods_cache[cache_key] = []
                return []
            result = self.normalize_periods(special.periods_json or [])
            self._store_periods_cache[cache_key] = [dict(period) for period in result]
            return result

        self._load_weekly_store_rules()
        for closure in self._closures_cache:
            if closure.closure_type == "specific_date" and closure.closed_date == resolved_date:
                self._store_periods_cache[cache_key] = []
                return []
            if (
                closure.closure_type == "date_range"
                and getattr(closure, "start_date", None)
                and getattr(closure, "end_date", None)
                and closure.start_date <= resolved_date <= closure.end_date
            ):
                self._store_periods_cache[cache_key] = []
                return []
            if closure.closure_type == "monthly_weekday" and self._is_monthly_closed(resolved_date, closure):
                self._store_periods_cache[cache_key] = []
                return []

        weekday_key = WEEKDAY_KEYS[resolved_date.weekday()]
        business_hour = self._business_hours_cache.get(weekday_key)
        if not business_hour or business_hour.is_closed:
            self._store_periods_cache[cache_key] = []
            return []
        result = self.normalize_periods(business_hour.periods_json or [])
        self._store_periods_cache[cache_key] = [dict(period) for period in result]
        return result
```

## A2:別の営業時間関数:休業日を先に適用

出典:`kirato0905-cmyk/beauty-links-backend` / `api/business_hours.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L287〜L322](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/business_hours.py#L287-L322)

```python
def get_hours_for_date(
    target_date: date,
    *,
    store_id: Optional[Any] = None,
    store_code: Optional[str] = None,
) -> List[Dict[str, str]]:
    calendar_cfg = _calendar_config(store_id=store_id, store_code=store_code)

    if not isinstance(target_date, date):
        raise TypeError("target_date must be datetime.date")

    target_date_str = target_date.strftime("%Y-%m-%d")

    closed_dates = calendar_cfg.get("closed_dates", [])
    if target_date_str in closed_dates:
        return []

    monthly_closed = calendar_cfg.get("monthly_closed", [])
    for rule in monthly_closed:
        if not isinstance(rule, dict):
            continue
        weekday = rule.get("weekday")
        weeks = rule.get("weeks", [])
        if isinstance(weeks, list) and _is_nth_weekday_of_month(target_date, weekday, weeks):
            return []

    special_hours = calendar_cfg.get("special_hours", [])
    for item in special_hours:
        if not isinstance(item, dict):
            continue
        if item.get("date") == target_date_str:
            return _normalize_hours(item.get("hours", []))

    business_hours = calendar_cfg.get("business_hours", {})
    return _normalize_hours(business_hours.get(_weekday_key(target_date), []))
```

## A3:予約枠が不成立となる理由

出典:`kirato0905-cmyk/beauty-links-backend` / `api/services/availability_service.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L550〜L575](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/services/availability_service.py#L550-L575)

```python
    def get_availability_reason(
        self,
        staff: StaffMember,
        target_date: Any,
        start_time: Any,
        end_time: Any,
        exclude_reservation_id: Optional[str] = None,
    ) -> str:
        resolved_date = self.parse_date(target_date)
        store_periods = self.get_store_periods(resolved_date)
        if not store_periods:
            return "off"

        staff_periods = self.get_staff_periods(staff, resolved_date, store_periods)
        if not staff_periods:
            return "off"

        if not self.range_fits_periods(staff_periods, start_time, end_time):
            return "outside"

        for existing in self._active_reservations_for_staff(resolved_date, staff.name):
            if self._is_excluded_reservation(existing, exclude_reservation_id):
                continue
            if self.overlaps(start_time, end_time, existing.start_time, existing.end_time):
                return "busy"
        return "ok"
```

## A4:管理画面のセット所要時間の優先順

出典:`kirato0905-cmyk/beauty-links-backend` / `api/admin/routes.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L7415〜L7469](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/admin/routes.py#L7415-L7469)

```python
def _resolve_customer_featured_set_duration_for_admin(
    db: Session,
    admin: AdminContext,
    customer: Optional[Customer],
    featured_set: FeaturedSet,
    fallback_duration: int,
    prefer_service_override_duration: bool = False,
) -> tuple[int, str]:
    """管理画面予約用のセット所要時間解決。

    優先順位:
      1. customer_featured_set_duration_overrides.duration_minutes
      2. auto-detected set かつ単品顧客別所要時間がある場合、その合計
      3. featured_sets.duration_minutes
      4. 単品メニュー所要時間の合計
      5. fallback 60分
    """
    if customer:
        override = (
            db.query(CustomerFeaturedSetDurationOverride)
            .filter(
                CustomerFeaturedSetDurationOverride.store_id == admin.store_id,
                CustomerFeaturedSetDurationOverride.customer_id == customer.id,
                CustomerFeaturedSetDurationOverride.featured_set_id == featured_set.id,
                CustomerFeaturedSetDurationOverride.is_active.is_(True),
            )
            .first()
        )
        if override and int(override.duration_minutes or 0) > 0:
            return int(override.duration_minutes or 0), "customer_featured_set_override"

    # 管理画面では featured_set_id を明示せず、単品メニュー選択が
    # featured_set_services と完全一致しただけで「自動セット判定」になるケースがある。
    # その場合に featured_sets.duration_minutes を優先すると、
    # 顧客別の単品所要時間が反映されない。
    #
    # そのため、自動セット判定かつ単品側に顧客別所要時間がある場合は、
    # featured_sets.duration_minutes より単品顧客別所要時間の合計を優先する。
    if prefer_service_override_duration and int(fallback_duration or 0) > 0:
        return int(fallback_duration or 0), "customer_service_override_auto_set"

    if int(featured_set.duration_minutes or 0) > 0:
        return int(featured_set.duration_minutes or 0), "featured_set_default"

    if int(fallback_duration or 0) > 0:
        return int(fallback_duration or 0), "service_sum_fallback"

    return 60, "fallback"


def _resolve_admin_reservation_menu_for_admin(
    db: Session,
    admin: AdminContext,
    customer: Optional[Customer],
    ordered_services: List[Service],
```

## P1:誕生日手動配信の実行引数

出典:`kirato0905-cmyk/beauty-links-backend` / `api/admin/coupon_routes.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L543〜L568](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/admin/coupon_routes.py#L543-L568)

```python
@router.post("/birthday/run-now")
def run_birthday_coupon_now(
    confirmed: bool = Query(default=False),
    admin: AdminContext = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """今日の対象者へ誕生日クーポンを手動テスト配信するAPI。

    send_timeは無視する。誕生日/配布日前倒し条件は維持するが、
    テスト確認のため同じ顧客・同じ年でも毎回新しいクーポンを発行できる。
    自動スケジューラ側の年次重複防止は変更しない。
    """
    ensure_coupon_write(admin)
    if not confirmed:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail={"code": "CONFIRMATION_REQUIRED", "message": "実行には確認が必要です。"},
        )
    birthday = BirthdayCouponService(db, admin.store_id, timezone_name=admin.timezone)
    result = birthday.run_due(
        force_time=True,
        allow_repeat_per_customer=True,
    )
    db.commit()
    return {"success": True, "data": result}
```

## P2:誕生日配信対象の絞り込み

出典:`kirato0905-cmyk/beauty-links-backend` / `api/services/birthday_coupon_service.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L312〜L334](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/services/birthday_coupon_service.py#L312-L334)

```python
    def _eligible_customers(self, target_birthday: date, *, require_consent: bool) -> List[Customer]:
        query = self.db.query(Customer).filter(
            Customer.store_id == self.store_id,
            Customer.deleted_at.is_(None),
            Customer.status == "active",
            Customer.birthday.is_not(None),
            Customer.line_user_id.is_not(None),
        )
        if require_consent:
            query = query.filter(Customer.consented.is_(True))
        rows = query.order_by(Customer.id.asc()).all()
        eligible: List[Customer] = []
        for customer in rows:
            line_user_id = str(customer.line_user_id or "").strip()
            if not line_user_id or line_user_id.startswith("admin_manual:"):
                continue
            birthday = customer.birthday
            if not birthday:
                continue
            resolved = _birthday_for_year(birthday, target_birthday.year)
            if resolved == target_birthday:
                eligible.append(customer)
        return eligible
```

## P3:手動配信は年次重複防止とは別の発行

出典:`kirato0905-cmyk/beauty-links-backend` / `api/services/birthday_coupon_service.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L430〜L455](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/services/birthday_coupon_service.py#L430-L455)

```python
        delivery_service = CouponDeliveryService(self.db, self.store_id)

        for customer in customers:
            # Automatic delivery keeps the stable annual source key so the scheduler
            # remains idempotent. Manual test delivery gets a unique source key and
            # explicitly allows repeated coupons for the same customer.
            if allow_repeat_per_customer:
                source_key = (
                    f"birthday-test:{customer.id}:{target_birthday.year}:"
                    f"{uuid.uuid4().hex}"
                )
                existing = None
            else:
                source_key = f"birthday:{customer.id}:{target_birthday.year}"

            try:
                if not allow_repeat_per_customer:
                    existing = coupon_service.repository.offered_coupon_for_source(
                        campaign_id=campaign.id,
                        customer_id=customer.id,
                        source_key=source_key,
                    )
                row = coupon_service.offer(
                    campaign_id=campaign.id,
                    customer_id=customer.id,
                    source_type=BIRTHDAY_SOURCE_TYPE,
```

## P4:紹介された方の初回来店済みで判定

出典:`kirato0905-cmyk/beauty-links-backend` / `api/services/referral_service.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L945〜L957](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/services/referral_service.py#L945-L957)

```python
    def process_completed_reservation(self, reservation: Reservation) -> Optional[Dict[str, Any]]:
        if not reservation or reservation.store_id != self.store_id or reservation.status != "completed":
            return None
        if not reservation.customer_id:
            return None
        referral = (
            self.db.query(CustomerReferral)
            .filter(
                CustomerReferral.store_id == self.store_id,
                CustomerReferral.referred_customer_id == reservation.customer_id,
                CustomerReferral.status.in_(["registered", "qualified", "rewarded"]),
            )
            .with_for_update()
```

[元コード L993〜L1024](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/services/referral_service.py#L993-L1024)

```python
                return {"qualified": False, "reason": "reservation_before_referral_registration"}

        earlier = (
            self.db.query(Reservation.id)
            .filter(
                Reservation.store_id == self.store_id,
                Reservation.customer_id == reservation.customer_id,
                Reservation.status == "completed",
                Reservation.deleted_at.is_(None),
                Reservation.id != reservation.id,
                Reservation.completed_at.is_not(None),
                Reservation.completed_at < (reservation.completed_at or self.now()),
            )
            .first()
        )
        if earlier:
            referral.status = "rejected"
            referral.rejection_reason = "not_first_completed_visit"
            self.db.flush()
            return {"qualified": False, "reason": "not_first_completed_visit"}

        referral.status = "qualified"
        referral.qualified_reservation_id = reservation.id
        referral.qualified_at = reservation.completed_at or self.now()
        referral.rejection_reason = None
        self.db.flush()

        # Make sure the referred-side registration reward is not missed.
        if self.ensure_settings().referred_reward_enabled and not referral.referred_coupon_id:
            self.issue_referred_reward(referral)

        reward = self.issue_referrer_reward(referral)
```

## T1:発送登録から送る内容

出典:`kirato0905-cmyk/BeautyLinks_admin_web` / `components/admin/RetailProposalPanel.tsx`
確認コミット:`6d6e049850867421c7c41f80190d64123ef1ff0b`

[元コード L1193〜L1213](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/components/admin/RetailProposalPanel.tsx#L1193-L1213)

```tsx
  async function shipOrder(order: RetailOrderRow) {
    const draft = shipmentDraft(order);
    const ok = await confirm({
      title: "発送完了にしますか?",
      description:
        "注文を発送済みに変更し、お客様へ発送完了LINEを送信します。",
      confirmLabel: "発送完了にする",
      danger: false,
    });
    if (!ok) return;

    await runAction(`ship-${order.id}`, async (accessToken) => {
      const payload: RetailShipmentPayload = {
        carrier_code: YAMATO_CARRIER_CODE,
        shipping_note: draft.shipping_note,
      };
      const response = await shipAdminRetailOrder(
        accessToken,
        order.id,
        payload,
      );
```

## T2:現行発送フォームは発送メモ

出典:`kirato0905-cmyk/BeautyLinks_admin_web` / `components/admin/RetailProposalPanel.tsx`
確認コミット:`6d6e049850867421c7c41f80190d64123ef1ff0b`

[元コード L1939〜L1968](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/components/admin/RetailProposalPanel.tsx#L1939-L1968)

```tsx
                            <h5>発送後、発送完了にする</h5>
                            <div className="retailShipmentForm">
                              <label>
                                <span>発送メモ(任意)</span>
                                <input
                                  value={draft.shipping_note}
                                  onChange={(event) =>
                                    patchShipment(order.id, {
                                      shipping_note: event.target.value,
                                    })
                                  }
                                  placeholder="例:セブン‐イレブンから発送"
                                />
                              </label>
                            </div>
                            <p className="retailProcedureNote">
                              荷物をコンビニまたはヤマト営業所へ預けた後に
                              押してください。お客様へ発送完了LINEが送信されます。
                            </p>
                            <div className="retailProcedureActions">
                              <button
                                className="primary"
                                type="button"
                                onClick={() => void shipOrder(order)}
                                disabled={Boolean(actionKey)}
                                aria-busy={actionKey === `ship-${order.id}`}
                              >
                                {actionKey === `ship-${order.id}`
                                  ? "発送処理中..."
                                  : "発送完了にしてLINEを送る"}
```

## T3:画面の返金は残りの対象額すべて

出典:`kirato0905-cmyk/BeautyLinks_admin_web` / `components/admin/RetailProposalPanel.tsx`
確認コミット:`6d6e049850867421c7c41f80190d64123ef1ff0b`

[元コード L1377〜L1397](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/components/admin/RetailProposalPanel.tsx#L1377-L1397)

```tsx
  async function refundOrder(order: RetailOrderRow) {
    const remaining = Number(order.refundable_amount || 0);
    if (remaining <= 0) return;
    const ok = await confirm({
      title: "この注文を全額返金しますか?",
      description: `${order.order_code}/${order.product_name}を${money(remaining)}返金します。発送前・未受取の商品は在庫へ戻します。`,
      confirmLabel: "返金する",
      danger: true,
    });
    if (!ok) return;
    await runAction(`refund-${order.id}`, async (accessToken) => {
      await refundAdminRetailOrder(accessToken, order.id, {
        amount: remaining,
        reason: "requested_by_customer",
        restock: !["completed", "shipped", "delivered"].includes(order.status),
      });
      setNotice("Stripe返金を受け付けました。");
    });
  }

  if (loading) return <div className="retailEmpty">店販情報を読み込んでいます...</div>;
```

## T4:一括発送準備の対象と結果

出典:`kirato0905-cmyk/BeautyLinks_admin_web` / `components/admin/RetailProposalPanel.tsx`
確認コミット:`6d6e049850867421c7c41f80190d64123ef1ff0b`

[元コード L1277〜L1308](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/components/admin/RetailProposalPanel.tsx#L1277-L1308)

```tsx
  async function bulkPrepareSelectedOrders() {
    const ids = selectedShippingOrders
      .filter((order) => order.status === "paid")
      .map((order) => order.id);

    if (ids.length === 0) {
      setError("決済済みの配送注文を選択してください。");
      return;
    }

    const ok = await confirm({
      title: `${ids.length}件を発送準備中にしますか?`,
      description:
        "選択した決済済み配送注文を一括で発送準備中へ変更します。",
      confirmLabel: "一括で発送準備",
      danger: false,
    });
    if (!ok) return;

    await runAction("bulk-prepare", async (accessToken) => {
      const response =
        await bulkPrepareAdminRetailShippingOrders(
          accessToken,
          ids,
        );

      setNotice(
        `${response.data.success_count}件を発送準備中にしました。`
        + `失敗 ${response.data.failed_count}件。`,
      );
    });
  }
```

## T5:原価などが不明なら利益は未算出

出典:`kirato0905-cmyk/beauty-links-backend` / `api/services/retail_order_finance_service.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L266〜L286](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/services/retail_order_finance_service.py#L266-L286)

```python
    @staticmethod
    def serialize(order, row):
        snapshot = dict(row.stripe_snapshot_json or {})
        current = snapshot.get("order_refunded_amount") == int(order.refunded_amount or 0)
        ready = current and snapshot.get("state") == "ready" and not snapshot.get("sync_error")
        net = snapshot.get("net_received") if ready else None
        shipping_cost = row.shipping_cost if order.pickup_method == "shipping" else 0
        profit = net - row.product_cost - shipping_cost if net is not None and row.product_cost is not None and shipping_cost is not None else None
        return {
            "order_id": str(order.id), "currency": order.currency or "jpy",
            "shipping_fee": int(order.shipping_fee or 0), "shipping_cost": shipping_cost,
            "product_cost": row.product_cost, "payment_fees": snapshot.get("payment_fees") if ready else None,
            "net_received": net, "profit": profit,
            "payout_date": snapshot.get("payout_date") if current else None,
            "payout_date_kind": snapshot.get("payout_date_kind", "unknown") if current else "unknown",
            "payout_status": snapshot.get("payout_status", "unknown"),
            "payout_schedule": snapshot.get("payout_schedule", ""),
            "balance_available_date": snapshot.get("balance_available_date"),
            "note": snapshot.get("note", ""), "sync_error": bool(snapshot.get("sync_error")),
            "stripe_environment": stripe_environment_for_order(order),
            "synced_at": row.synced_at.isoformat() if row.synced_at else None,
```

## X1:閲覧者の書き込みはサーバー側で拒否

出典:`kirato0905-cmyk/beauty-links-backend` / `api/admin/routes.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L164〜L174](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/admin/routes.py#L164-L174)

```python
def ensure_viewer_cannot_write(admin: AdminContext) -> None:
    """閲覧者権限は管理画面の追加・変更・削除・編集系APIを実行できない。"""
    role = _normalized_admin_role(admin)
    if role == "viewer":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail={
                "code": "VIEWER_WRITE_FORBIDDEN",
                "message": "閲覧のみ権限では、追加・変更・削除・編集はできません。",
            },
        )
```

## X2:売上向上はサイドバーで追加条件がある

出典:`kirato0905-cmyk/BeautyLinks_admin_web` / `components/admin-sidebar.tsx`
確認コミット:`6d6e049850867421c7c41f80190d64123ef1ff0b`

[元コード L1069〜L1103](https://github.com/kirato0905-cmyk/BeautyLinks_admin_web/blob/6d6e049850867421c7c41f80190d64123ef1ff0b/components/admin-sidebar.tsx#L1069-L1103)

```tsx
    if (item.allowedRoles.length === 0) return false;

    if (item.key === "automation") {
      // 売上向上は権限のあるロールだけに表示する。
      // staff / viewer には、Growth選択中・支払い済みかどうかに関係なく表示しない。
      const automationRoleAllowed =
        adminRole === "owner" ||
        adminRole === "manager" ||
        adminRole === "platform_admin" ||
        adminRole === "service_admin";

      if (!automationRoleAllowed) {
        return false;
      }

      // BeautyLinks運営者は店舗の契約プランに関係なく、
      // サポート・検証用として売上向上を常に表示する。
      if (operatorBillingBypass) {
        return true;
      }

      // 通常のowner/managerはGrowthを選択した時点で表示する。
      // 未払いなら後段でロック表示にし、クリックはできない。
      // プラン変更直後などで旧Growth entitlementが有効な間は
      // selected=false でも effective=true なら引き続き表示する。
      return (
        !billingStateReadyForSelectedStore ||
        effectiveSalesGrowthSelected ||
        effectiveSalesGrowthEnabled
      );
    }

    if (item.key === "platformStores") {
      return (
        (adminRole === "platform_admin" ||
```

## X3:契約の利用権と猶予期間

出典:`kirato0905-cmyk/beauty-links-backend` / `api/services/billing_access_service.py`
確認コミット:`da44d42f18366ffd824ee77d0a0d5ada9e3af059`

[元コード L75〜L107](https://github.com/kirato0905-cmyk/beauty-links-backend/blob/da44d42f18366ffd824ee77d0a0d5ada9e3af059/api/services/billing_access_service.py#L75-L107)

```python
def subscription_access_is_effective(
    subscription: Optional[StoreSubscription],
    *,
    now: Optional[datetime] = None,
) -> bool:
    """Return True when the current paid/trial BeautyLinks entitlement is valid."""
    if subscription is None:
        return False

    # access_enabled is set only after the Stripe paid-entitlement flow succeeds.
    if not bool(getattr(subscription, "access_enabled", False)):
        return False

    # A valid paid entitlement must identify what was actually paid for.
    if not str(getattr(subscription, "entitled_plan_code", "") or "").strip():
        return False

    status_value = str(getattr(subscription, "status", "") or "").strip().lower()
    if status_value in {"active", "trialing"}:
        return True

    if status_value not in GRACE_ELIGIBLE_STATUSES:
        return False

    grace_end = _utc_datetime(getattr(subscription, "grace_period_ends_at", None))
    if grace_end is None:
        return False

    current = subscription_reference_now(
        subscription,
        now=now,
    )
    return current < grace_end
```

SHA-256: d3e4c8964c947509be0fa42d09fc4f64e096fb38710ddc09e0da41f2854e8b9d