Implements, verifies, and archives the three remaining Wave 2 changes from openspec/MIGRATION-PLAN.md. - add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and one-time admin review, paginated own fund entries, idempotent per-shop weekly/monthly settlement statements with commission rate and one-time payout confirmation. - add-merchant-onboarding (P5): personal/enterprise applications with one live application per user, guarded review with mandatory rejection reason, and transactional shop + owner provisioning returning one-time credentials; mall onboarding/status pages and an admin review console. - add-membership-messaging (P7): platform member levels, append-only growth accrual on order completion with guarded one-way leveling, order/shipment/ refund system messages with unread/read state and soft deletion, plus the mall header unread badge. Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding, membership and messaging modules, event hooks in order/fulfillment/aftersale, and integration suites for each. Shared contract extended and all three frontends updated; code indexes, domain docs, backend guidelines and the migration tracker synced. Verification: cargo test -p vmall-api green twice consecutively; mall, admin and shop-admin builds pass; browser smoke on every new surface; openspec validate --all --strict green (33 passed). The three changes share the @vmall/shared contract, the mall mock adapter and per-app locale/nav files, so they are committed together to keep every commit buildable.
412 lines
16 KiB
Vue
412 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { ApiError } from "@vmall/shared";
|
|
import type {
|
|
Paged,
|
|
SettlementPeriodKind,
|
|
SettlementStatement,
|
|
SettlementStatementDetail,
|
|
SettlementStatus,
|
|
} from "@vmall/shared";
|
|
|
|
definePageMeta({ middleware: "auth" });
|
|
|
|
const { $api } = useNuxtApp();
|
|
const { locale, t: translate } = useI18n();
|
|
const { load: loadMoney, fmt } = useMoney();
|
|
|
|
const statements = ref<Paged<SettlementStatement> | null>(null);
|
|
const page = ref(1);
|
|
const statusFilter = ref<"" | SettlementStatus>("");
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const serverError = ref("");
|
|
const notice = ref("");
|
|
|
|
const detailId = ref("");
|
|
const detail = ref<SettlementStatementDetail | null>(null);
|
|
const detailLoading = ref(false);
|
|
const detailError = ref("");
|
|
|
|
const periodKind = ref<SettlementPeriodKind>("month");
|
|
const periodDate = ref(defaultPeriodDate());
|
|
const generating = ref(false);
|
|
const generated = ref<SettlementStatement | null>(null);
|
|
const generatedExisting = ref(false);
|
|
|
|
/** Mid-point of the previous month: safely inside a closed period. */
|
|
function defaultPeriodDate(): string {
|
|
const now = new Date();
|
|
const previous = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 15));
|
|
return previous.toISOString().slice(0, 10);
|
|
}
|
|
|
|
const selectClass =
|
|
"border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2";
|
|
|
|
function formatDate(value: string): string {
|
|
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
|
}
|
|
|
|
function statusTone(value: SettlementStatus): "green" | "orange" {
|
|
return value === "confirmed" ? "green" : "orange";
|
|
}
|
|
|
|
/** Integer basis points to a fixed 2-decimal percentage (no float money math). */
|
|
function formatBps(bps: number): string {
|
|
const whole = Math.trunc(bps / 100);
|
|
const fraction = Math.abs(bps % 100)
|
|
.toString()
|
|
.padStart(2, "0");
|
|
return `${whole}.${fraction}%`;
|
|
}
|
|
|
|
function periodKindLabel(kind: SettlementPeriodKind): string {
|
|
return kind === "week"
|
|
? translate("shop.settlement.periodWeek")
|
|
: translate("shop.settlement.periodMonth");
|
|
}
|
|
|
|
async function loadStatements(): Promise<void> {
|
|
loading.value = true;
|
|
error.value = "";
|
|
serverError.value = "";
|
|
try {
|
|
statements.value = await $api.shop.listShopSettlementStatements({
|
|
page: page.value,
|
|
status: statusFilter.value || undefined,
|
|
});
|
|
} catch (err: unknown) {
|
|
error.value = err instanceof Error ? err.message : translate("common.error");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function closeDetail(): void {
|
|
detailId.value = "";
|
|
detail.value = null;
|
|
detailError.value = "";
|
|
}
|
|
|
|
async function changePage(next: number): Promise<void> {
|
|
if (!statements.value || next < 1) return;
|
|
if (next > Math.ceil(statements.value.total / statements.value.per_page)) return;
|
|
page.value = next;
|
|
closeDetail();
|
|
await loadStatements();
|
|
}
|
|
|
|
async function changeStatus(): Promise<void> {
|
|
page.value = 1;
|
|
closeDetail();
|
|
await loadStatements();
|
|
}
|
|
|
|
async function toggleDetail(id: string): Promise<void> {
|
|
if (detailId.value === id) {
|
|
closeDetail();
|
|
return;
|
|
}
|
|
detailId.value = id;
|
|
detail.value = null;
|
|
detailError.value = "";
|
|
detailLoading.value = true;
|
|
try {
|
|
detail.value = await $api.shop.getShopSettlementStatement(id);
|
|
} catch (err: unknown) {
|
|
detailError.value = err instanceof Error ? err.message : translate("common.error");
|
|
} finally {
|
|
detailLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function generate(): Promise<void> {
|
|
const date = periodDate.value.trim();
|
|
if (!date) {
|
|
error.value = translate("shop.settlement.dateRequired");
|
|
return;
|
|
}
|
|
generating.value = true;
|
|
error.value = "";
|
|
serverError.value = "";
|
|
notice.value = "";
|
|
generated.value = null;
|
|
generatedExisting.value = false;
|
|
try {
|
|
// Snapshot the existing statement ids first so an idempotent repeat can be
|
|
// reported as "existing" rather than "new". 100 rows covers years of
|
|
// weekly statements for a single shop.
|
|
const known = await $api.shop.listShopSettlementStatements({ page: 1, per_page: 100 });
|
|
const knownIds = new Set(known.items.map((item) => item.id));
|
|
const statement = await $api.shop.generateShopSettlementStatement({
|
|
period_kind: periodKind.value,
|
|
period_start: date,
|
|
});
|
|
generated.value = statement;
|
|
generatedExisting.value = knownIds.has(statement.id);
|
|
notice.value = generatedExisting.value
|
|
? translate("shop.settlement.generatedExisting")
|
|
: translate("shop.settlement.generatedNew");
|
|
page.value = 1;
|
|
await loadStatements();
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError && err.status === 409) {
|
|
// The period is not closed yet; keep the server message visible too.
|
|
error.value = translate("shop.settlement.notClosed");
|
|
serverError.value = err.message;
|
|
} else {
|
|
error.value = err instanceof Error ? err.message : translate("common.error");
|
|
}
|
|
} finally {
|
|
generating.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadMoney();
|
|
loadStatements();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<VPage :title="$t('nav.settlements')">
|
|
<div v-if="error" class="text-danger my-2 text-sm" role="alert">
|
|
{{ error }}<span v-if="serverError" class="text-muted"> — {{ serverError }}</span>
|
|
</div>
|
|
<div v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</div>
|
|
|
|
<VCard>
|
|
<h2 class="mb-4 text-lg font-semibold">{{ $t("shop.settlement.generate") }}</h2>
|
|
<div class="grid gap-3 md:grid-cols-3">
|
|
<VField :label="$t('shop.settlement.periodKind')">
|
|
<select id="settlement-period-kind" v-model="periodKind" :class="selectClass">
|
|
<option value="week">{{ $t("shop.settlement.periodWeek") }}</option>
|
|
<option value="month">{{ $t("shop.settlement.periodMonth") }}</option>
|
|
</select>
|
|
</VField>
|
|
<VField :label="$t('shop.settlement.periodDate')">
|
|
<VInput id="settlement-period-date" v-model="periodDate" type="date" />
|
|
</VField>
|
|
<div class="mb-3.5 flex items-end">
|
|
<VBtn variant="primary" :disabled="generating" @click="generate">
|
|
{{ generating ? $t("common.loading") : $t("shop.settlement.generate") }}
|
|
</VBtn>
|
|
</div>
|
|
</div>
|
|
<p class="text-muted text-xs">{{ $t("shop.settlement.generateHint") }}</p>
|
|
|
|
<div v-if="generated" class="border-border bg-bg mt-4 rounded-md border p-4">
|
|
<div class="mb-3 flex flex-wrap items-center gap-2">
|
|
<VBadge :tone="generatedExisting ? 'gray' : 'blue'">{{
|
|
generatedExisting
|
|
? $t("shop.settlement.existingStatement")
|
|
: $t("shop.settlement.newStatement")
|
|
}}</VBadge>
|
|
<VBadge :tone="statusTone(generated.status)">{{
|
|
$t(`shop.settlement.status.${generated.status}`)
|
|
}}</VBadge>
|
|
<strong>{{ periodKindLabel(generated.period_kind) }}</strong>
|
|
<span class="text-muted text-sm"
|
|
>{{ generated.period_start }} → {{ generated.period_end }}</span
|
|
>
|
|
</div>
|
|
<div class="grid [grid-template-columns:repeat(auto-fit,minmax(160px,1fr))] gap-4">
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.orderCount") }}</span>
|
|
<strong>{{ generated.order_count }}</strong>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.gross") }}</span>
|
|
<strong>{{ fmt(generated.gross_minor, generated.currency) }}</strong>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.refund") }}</span>
|
|
<strong>{{ fmt(generated.refund_minor, generated.currency) }}</strong>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.commissionRate") }}</span>
|
|
<strong>{{ formatBps(generated.commission_rate_bps) }}</strong>
|
|
<span class="text-muted text-xs">{{ generated.commission_rate_bps }} bps</span>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.commission") }}</span>
|
|
<strong>{{ fmt(generated.commission_minor, generated.currency) }}</strong>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.payable") }}</span>
|
|
<strong>{{ fmt(generated.payable_minor, generated.currency) }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</VCard>
|
|
|
|
<div class="mt-6 mb-3 flex items-center gap-3">
|
|
<label for="settlement-status" class="text-sm font-medium">{{
|
|
$t("shop.filterStatus")
|
|
}}</label>
|
|
<select
|
|
id="settlement-status"
|
|
v-model="statusFilter"
|
|
:class="selectClass"
|
|
class="max-w-48"
|
|
@change="changeStatus"
|
|
>
|
|
<option value="">{{ $t("common.all") }}</option>
|
|
<option value="pending">{{ $t("shop.settlement.status.pending") }}</option>
|
|
<option value="confirmed">{{ $t("shop.settlement.status.confirmed") }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
|
<VCard v-else-if="!statements?.items.length" class="text-muted">{{
|
|
$t("common.empty")
|
|
}}</VCard>
|
|
<template v-else-if="statements">
|
|
<VTable>
|
|
<thead>
|
|
<tr>
|
|
<th>{{ $t("shop.settlement.period") }}</th>
|
|
<th>{{ $t("shop.settlement.orderCount") }}</th>
|
|
<th>{{ $t("shop.settlement.gross") }}</th>
|
|
<th>{{ $t("shop.settlement.refund") }}</th>
|
|
<th>{{ $t("shop.settlement.commissionRate") }}</th>
|
|
<th>{{ $t("shop.settlement.payable") }}</th>
|
|
<th>{{ $t("common.status") }}</th>
|
|
<th>{{ $t("shop.settlement.confirmedAt") }}</th>
|
|
<th>{{ $t("common.actions") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="statement in statements.items" :key="statement.id">
|
|
<tr>
|
|
<td>
|
|
<div class="font-medium">{{ periodKindLabel(statement.period_kind) }}</div>
|
|
<div class="text-muted text-xs">
|
|
{{ statement.period_start }} → {{ statement.period_end }}
|
|
</div>
|
|
</td>
|
|
<td>{{ statement.order_count }}</td>
|
|
<td>{{ fmt(statement.gross_minor, statement.currency) }}</td>
|
|
<td>{{ fmt(statement.refund_minor, statement.currency) }}</td>
|
|
<td>
|
|
<div>{{ formatBps(statement.commission_rate_bps) }}</div>
|
|
<div class="text-muted text-xs">
|
|
{{ fmt(statement.commission_minor, statement.currency) }}
|
|
</div>
|
|
</td>
|
|
<td>{{ fmt(statement.payable_minor, statement.currency) }}</td>
|
|
<td>
|
|
<VBadge :tone="statusTone(statement.status)">{{
|
|
$t(`shop.settlement.status.${statement.status}`)
|
|
}}</VBadge>
|
|
</td>
|
|
<td>{{ statement.confirmed_at ? formatDate(statement.confirmed_at) : "—" }}</td>
|
|
<td>
|
|
<VBtn size="sm" @click="toggleDetail(statement.id)">{{
|
|
detailId === statement.id
|
|
? $t("shop.settlement.hideDetail")
|
|
: $t("shop.details")
|
|
}}</VBtn>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="detailId === statement.id">
|
|
<td colspan="9">
|
|
<p v-if="detailLoading" class="text-muted">{{ $t("common.loading") }}</p>
|
|
<p v-else-if="detailError" class="text-danger text-sm" role="alert">
|
|
{{ detailError }}
|
|
</p>
|
|
<div v-else-if="detail">
|
|
<h3 class="mb-3 text-base font-semibold">
|
|
{{ $t("shop.settlement.detail") }}
|
|
</h3>
|
|
<div class="mb-4 grid [grid-template-columns:repeat(auto-fit,minmax(160px,1fr))] gap-4">
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.orderCount") }}</span>
|
|
<strong>{{ detail.order_count }}</strong>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.gross") }}</span>
|
|
<strong>{{ fmt(detail.gross_minor, detail.currency) }}</strong>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.refund") }}</span>
|
|
<strong>{{ fmt(detail.refund_minor, detail.currency) }}</strong>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{
|
|
$t("shop.settlement.commissionRate")
|
|
}}</span>
|
|
<strong>{{ formatBps(detail.commission_rate_bps) }}</strong>
|
|
<span class="text-muted text-xs">{{ detail.commission_rate_bps }} bps</span>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.commission") }}</span>
|
|
<strong>{{ fmt(detail.commission_minor, detail.currency) }}</strong>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.payable") }}</span>
|
|
<strong>{{ fmt(detail.payable_minor, detail.currency) }}</strong>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-muted text-sm">{{ $t("shop.settlement.confirmedAt") }}</span>
|
|
<strong>{{ detail.confirmed_at ? formatDate(detail.confirmed_at) : "—" }}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="mb-3 text-base font-semibold">
|
|
{{ $t("shop.settlement.orders") }}
|
|
</h3>
|
|
<p v-if="!detail.orders.length" class="text-muted">
|
|
{{ $t("shop.settlement.noOrders") }}
|
|
</p>
|
|
<VTable v-else>
|
|
<thead>
|
|
<tr>
|
|
<th>{{ $t("shop.settlement.orderNo") }}</th>
|
|
<th>{{ $t("shop.settlement.orderCurrency") }}</th>
|
|
<th>{{ $t("shop.settlement.gross") }}</th>
|
|
<th>{{ $t("shop.settlement.refund") }}</th>
|
|
<th>{{ $t("shop.created") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="line in detail.orders" :key="line.order_id">
|
|
<td>
|
|
<NuxtLink
|
|
:to="`/orders/${line.order_id}`"
|
|
class="text-primary hover:underline"
|
|
>{{ line.order_no }}</NuxtLink
|
|
>
|
|
</td>
|
|
<td>{{ line.order_currency }}</td>
|
|
<td>{{ fmt(line.gross_minor, detail.currency) }}</td>
|
|
<td>{{ fmt(line.refund_minor, detail.currency) }}</td>
|
|
<td>{{ formatDate(line.created_at) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</VTable>
|
|
<div v-if="statements.total > statements.per_page" class="mt-4 flex items-center justify-between">
|
|
<VBtn size="sm" :disabled="page <= 1 || loading" @click="changePage(page - 1)">{{
|
|
$t("common.prev")
|
|
}}</VBtn>
|
|
<span class="text-muted text-sm"
|
|
>{{ $t("common.page") }} {{ page }} /
|
|
{{ Math.ceil(statements.total / statements.per_page) }}</span
|
|
>
|
|
<VBtn
|
|
size="sm"
|
|
:disabled="page >= Math.ceil(statements.total / statements.per_page) || loading"
|
|
@click="changePage(page + 1)"
|
|
>{{ $t("common.next") }}</VBtn
|
|
>
|
|
</div>
|
|
</template>
|
|
</VPage>
|
|
</template>
|