Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
b6b728bc22
|
|
@ -39,7 +39,6 @@ import {
|
|||
Pill,
|
||||
Puzzle,
|
||||
ReceiptText,
|
||||
RotateCcw,
|
||||
School,
|
||||
ShieldCheck,
|
||||
ShoppingBag,
|
||||
|
|
@ -211,7 +210,6 @@ export const categories: Record<EntryType, Category[]> = {
|
|||
{ id: "bonus", label: "奖金", color: "#b57a0e", tint: "#fff7dd", icon: Gift },
|
||||
{ id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: HandCoins },
|
||||
{ id: "investment", label: "投资", color: "#28805d", tint: "#eaf7f1", icon: ChartNoAxesCombined },
|
||||
{ id: "refund", label: "报销", color: "#3c7d9b", tint: "#edf6fa", icon: RotateCcw },
|
||||
{ id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote },
|
||||
{ id: "received-red-packet", label: "红包", color: "#d94f45", tint: "#fff0ed", icon: WalletCards },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -108,6 +108,22 @@ db.version(5).stores({
|
|||
normalizeEntry(operation.payload);
|
||||
});
|
||||
});
|
||||
|
||||
db.version(6).stores({
|
||||
entries: "id, ownerId, *ledgerIds, reimbursementOfEntryId, occurredAt, updatedAt, deletedAt, conversionStatus",
|
||||
syncOperations: "id, *ledgerIds, createdAt, syncedAt",
|
||||
ledgers: "id, updatedAt, archivedAt",
|
||||
userPreferences: "id, userId, key, updatedAt",
|
||||
syncMetadata: "id, updatedAt",
|
||||
exchangeRates: "id, currency, requestedDate, updatedAt",
|
||||
}).upgrade(async (transaction) => {
|
||||
await transaction.table<LegacyEntry>("entries").toCollection().modify((entry) => {
|
||||
entry.reimbursementOfEntryId ??= null;
|
||||
});
|
||||
await transaction.table<LegacyOperation>("syncOperations").toCollection().modify((operation) => {
|
||||
operation.payload.reimbursementOfEntryId ??= null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const databaseCache = new Map<string, CentsDatabase>();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type EntryInput = {
|
|||
note: string;
|
||||
occurredAt: string;
|
||||
createdBy?: string;
|
||||
reimbursementOfEntryId?: string | null;
|
||||
};
|
||||
|
||||
const baseCurrency: CurrencyCode = "CNY";
|
||||
|
|
@ -33,6 +34,7 @@ export function createEntry(input: EntryInput): LedgerEntry {
|
|||
exchangeRateEffectiveDate: isCny ? exchangeRateDate : null,
|
||||
exchangeRateSource: "system",
|
||||
conversionStatus: isCny ? "exact" : "pending",
|
||||
reimbursementOfEntryId: input.reimbursementOfEntryId ?? null,
|
||||
categoryId: input.categoryId,
|
||||
note: input.note,
|
||||
occurredAt: input.occurredAt,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import type { LedgerEntry } from "@cents/domain";
|
||||
|
||||
export function isReimbursement(entry: LedgerEntry) {
|
||||
return entry.type === "income" && Boolean(entry.reimbursementOfEntryId);
|
||||
}
|
||||
|
||||
export function reimbursementsForExpense(entries: LedgerEntry[], expenseId: string) {
|
||||
return entries.filter((entry) =>
|
||||
entry.deletedAt === null
|
||||
&& entry.reimbursementOfEntryId === expenseId,
|
||||
);
|
||||
}
|
||||
|
||||
export function reimbursedOriginalAmount(expense: LedgerEntry, entries: LedgerEntry[]) {
|
||||
const amount = reimbursementsForExpense(entries, expense.id)
|
||||
.filter((entry) => entry.currency === expense.currency)
|
||||
.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
return Math.min(expense.amount, amount);
|
||||
}
|
||||
|
||||
export function reimbursedBaseAmount(expense: LedgerEntry, entries: LedgerEntry[]) {
|
||||
if (expense.baseAmount === null) return null;
|
||||
const reimbursed = reimbursedOriginalAmount(expense, entries);
|
||||
return Math.min(expense.baseAmount, Math.round(expense.baseAmount * reimbursed / expense.amount));
|
||||
}
|
||||
|
||||
export function netOriginalAmount(expense: LedgerEntry, entries: LedgerEntry[]) {
|
||||
return Math.max(0, expense.amount - reimbursedOriginalAmount(expense, entries));
|
||||
}
|
||||
|
||||
export function netBaseAmount(expense: LedgerEntry, entries: LedgerEntry[]) {
|
||||
if (expense.baseAmount === null) return null;
|
||||
return Math.max(0, expense.baseAmount - (reimbursedBaseAmount(expense, entries) ?? 0));
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { apiRequest } from "../data/api";
|
|||
import { getUserDb } from "../data/db";
|
||||
import { localDate } from "../data/entries";
|
||||
import { createId } from "../data/ids";
|
||||
import { reimbursementsForExpense, reimbursedOriginalAmount } from "../data/reimbursements";
|
||||
import { useAuthStore } from "./auth";
|
||||
import { useLedgerStore } from "./ledgers";
|
||||
|
||||
|
|
@ -186,6 +187,25 @@ export const useEntryStore = defineStore("entries", {
|
|||
},
|
||||
async addEntry(entry: LedgerEntry) {
|
||||
const auth = useAuthStore();
|
||||
if (entry.reimbursementOfEntryId) {
|
||||
const expense = this.entries.find((item) => item.id === entry.reimbursementOfEntryId);
|
||||
if (
|
||||
!expense
|
||||
|| expense.deletedAt
|
||||
|| expense.type !== "expense"
|
||||
|| expense.ownerId !== auth.user?.id
|
||||
) throw new Error("关联的原支出无效");
|
||||
const sameLedgers = [...entry.ledgerIds].sort().join(",") === [...expense.ledgerIds].sort().join(",");
|
||||
if (
|
||||
entry.type !== "income"
|
||||
|| entry.currency !== expense.currency
|
||||
|| entry.categoryId !== expense.categoryId
|
||||
|| !sameLedgers
|
||||
) throw new Error("报销必须继承原支出的币种、分类和账本");
|
||||
if (entry.amount > expense.amount - reimbursedOriginalAmount(expense, this.entries)) {
|
||||
throw new Error("报销金额超过剩余可报销金额");
|
||||
}
|
||||
}
|
||||
const db = await getUserDb(auth.user?.id ?? entry.ownerId);
|
||||
const operation: SyncOperation = {
|
||||
id: createId(),
|
||||
|
|
@ -213,6 +233,37 @@ export const useEntryStore = defineStore("entries", {
|
|||
const db = await getUserDb(auth.user?.id ?? entry.ownerId);
|
||||
const existing = await db.entries.get(entry.id);
|
||||
if (!existing || existing.deletedAt) throw new Error("Entry not found");
|
||||
if (existing.reimbursementOfEntryId !== entry.reimbursementOfEntryId) {
|
||||
throw new Error("不能修改报销关联");
|
||||
}
|
||||
if (entry.reimbursementOfEntryId) {
|
||||
const expense = this.entries.find((item) => item.id === entry.reimbursementOfEntryId);
|
||||
if (!expense || expense.deletedAt || expense.ownerId !== auth.user?.id) {
|
||||
throw new Error("关联的原支出无效");
|
||||
}
|
||||
const otherReimbursements = reimbursementsForExpense(this.entries, expense.id)
|
||||
.filter((item) => item.id !== entry.id)
|
||||
.reduce((sum, item) => sum + item.amount, 0);
|
||||
if (
|
||||
entry.type !== "income"
|
||||
|| entry.currency !== expense.currency
|
||||
|| entry.categoryId !== expense.categoryId
|
||||
|| entry.amount + otherReimbursements > expense.amount
|
||||
) throw new Error("报销金额超过剩余可报销金额");
|
||||
} else if (existing.type === "expense") {
|
||||
const reimbursed = reimbursedOriginalAmount(existing, this.entries);
|
||||
const sameLedgers = [...existing.ledgerIds].sort().join(",") === [...entry.ledgerIds].sort().join(",");
|
||||
if (
|
||||
reimbursed > entry.amount
|
||||
|| (reimbursed > 0 && (
|
||||
existing.currency !== entry.currency
|
||||
|| existing.categoryId !== entry.categoryId
|
||||
|| !sameLedgers
|
||||
))
|
||||
) {
|
||||
throw new Error("存在报销时不能缩小支出金额、改变分类、币种或账本");
|
||||
}
|
||||
}
|
||||
|
||||
const exchangeRateDate = localDate(entry.occurredAt);
|
||||
const conversionChanged = existing.amount !== entry.amount
|
||||
|
|
@ -333,6 +384,9 @@ export const useEntryStore = defineStore("entries", {
|
|||
const db = await getUserDb(auth.user.id);
|
||||
const existing = await db.entries.get(entryId);
|
||||
if (!existing || existing.deletedAt) return;
|
||||
if (existing.type === "expense" && reimbursementsForExpense(this.entries, entryId).length) {
|
||||
throw new Error("存在报销记录,不能删除原支出");
|
||||
}
|
||||
|
||||
const deletedAt = new Date().toISOString();
|
||||
const deleted: LedgerEntry = {
|
||||
|
|
|
|||
|
|
@ -321,6 +321,22 @@ input:focus-visible {
|
|||
color: #ffe6dc;
|
||||
}
|
||||
|
||||
.summary-expense-slide {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.summary-expense-slide small {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-size: 10px;
|
||||
line-height: 1.15;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ledger-content {
|
||||
position: relative;
|
||||
height: calc(100vh - 180px);
|
||||
|
|
@ -635,6 +651,15 @@ input:focus-visible {
|
|||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.entry-value .entry-original-amount {
|
||||
margin: 0 0 1px;
|
||||
color: #9aaaa5;
|
||||
font-size: 11px;
|
||||
line-height: 1.05;
|
||||
text-decoration: line-through;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.entry-value .expense {
|
||||
color: #d84c36;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
CircleUserRound,
|
||||
CloudOff,
|
||||
NotebookText,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
WalletCards,
|
||||
X,
|
||||
|
|
@ -29,6 +30,14 @@ import {
|
|||
type Category,
|
||||
} from "../data/categories";
|
||||
import { ledgerTheme } from "../data/ledgers";
|
||||
import { createEntry } from "../data/entries";
|
||||
import {
|
||||
isReimbursement,
|
||||
netOriginalAmount,
|
||||
reimbursementsForExpense,
|
||||
reimbursedOriginalAmount,
|
||||
} from "../data/reimbursements";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
import CurrencyPicker from "../components/CurrencyPicker.vue";
|
||||
|
|
@ -38,6 +47,7 @@ type CategorySheetMode = "parent" | "child";
|
|||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useEntryStore();
|
||||
const authStore = useAuthStore();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const entry = ref<LedgerEntry | null>(null);
|
||||
const loading = ref(true);
|
||||
|
|
@ -48,6 +58,11 @@ const categorySheetMode = ref<CategorySheetMode | null>(null);
|
|||
const ledgerPickerOpen = ref(false);
|
||||
const dateInput = ref<HTMLInputElement | null>(null);
|
||||
const currencyPickerOpen = ref(false);
|
||||
const reimbursementOpen = ref(false);
|
||||
const reimbursementAmount = ref("");
|
||||
const reimbursementOccurredAt = ref("");
|
||||
const reimbursementNote = ref("");
|
||||
const reimbursementSaving = ref(false);
|
||||
|
||||
const amount = ref("");
|
||||
const currency = ref<CurrencyCode>("CNY");
|
||||
|
|
@ -58,8 +73,10 @@ const note = ref("");
|
|||
const occurredAt = ref("");
|
||||
|
||||
const entryType = computed<EntryType>(() => entry.value?.type ?? "expense");
|
||||
const reimbursementEntry = computed(() => entry.value ? isReimbursement(entry.value) : false);
|
||||
const categoryEntryType = computed<EntryType>(() => reimbursementEntry.value ? "expense" : entryType.value);
|
||||
const typeOption = computed(() => entryTypes.find((type) => type.id === entryType.value)!);
|
||||
const parentCategories = computed(() => categories[entryType.value]);
|
||||
const parentCategories = computed(() => categories[categoryEntryType.value]);
|
||||
const selectedParent = computed(() =>
|
||||
parentCategories.value.find((category) => category.id === parentCategoryId.value),
|
||||
);
|
||||
|
|
@ -108,6 +125,29 @@ const conversionLabel = computed(() => {
|
|||
const rateDate = entry.value.exchangeRateEffectiveDate ?? entry.value.exchangeRateDate;
|
||||
return `约 ¥${(entry.value.baseAmount / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} · ${rateDate}${entry.value.conversionStatus === "fallback" ? " 最近汇率" : ""}`;
|
||||
});
|
||||
const sourceExpense = computed(() =>
|
||||
entry.value?.reimbursementOfEntryId
|
||||
? store.entries.find((item) => item.id === entry.value?.reimbursementOfEntryId) ?? null
|
||||
: null,
|
||||
);
|
||||
const expenseReimbursements = computed(() =>
|
||||
entry.value?.type === "expense" ? reimbursementsForExpense(store.entries, entry.value.id) : [],
|
||||
);
|
||||
const reimbursementLocked = computed(() => reimbursementEntry.value || expenseReimbursements.value.length > 0);
|
||||
const reimbursedAmount = computed(() =>
|
||||
entry.value?.type === "expense" ? reimbursedOriginalAmount(entry.value, store.entries) : 0,
|
||||
);
|
||||
const remainingReimbursement = computed(() =>
|
||||
entry.value?.type === "expense" ? Math.max(0, entry.value.amount - reimbursedAmount.value) : 0,
|
||||
);
|
||||
const netExpenseAmount = computed(() =>
|
||||
entry.value?.type === "expense" ? netOriginalAmount(entry.value, store.entries) : 0,
|
||||
);
|
||||
const canRecordReimbursement = computed(() =>
|
||||
entry.value?.type === "expense"
|
||||
&& entry.value.ownerId === authStore.user?.id
|
||||
&& remainingReimbursement.value > 0,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
|
||||
|
|
@ -124,7 +164,7 @@ function hydrate(value: LedgerEntry) {
|
|||
ledgerIds.value = [...new Set([...value.ledgerIds, ledgerStore.personalLedger?.id].filter((id): id is string => Boolean(id)))];
|
||||
note.value = value.note;
|
||||
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
|
||||
const path = findCategoryPath(value.type, value.categoryId);
|
||||
const path = findCategoryPath(value.reimbursementOfEntryId ? "expense" : value.type, value.categoryId);
|
||||
parentCategoryId.value = path[0]?.id ?? "";
|
||||
childCategoryId.value = path[1]?.id ?? "";
|
||||
}
|
||||
|
|
@ -160,6 +200,7 @@ function chooseChild(category: Category) {
|
|||
}
|
||||
|
||||
function toggleLedger(ledgerId: string) {
|
||||
if (reimbursementEntry.value) return;
|
||||
if (ledgerId === ledgerStore.personalLedger?.id) return;
|
||||
if (ledgerIds.value.includes(ledgerId)) {
|
||||
if (ledgerIds.value.length > 1) ledgerIds.value = ledgerIds.value.filter((id) => id !== ledgerId);
|
||||
|
|
@ -168,6 +209,78 @@ function toggleLedger(ledgerId: string) {
|
|||
ledgerIds.value = [...ledgerIds.value, ledgerId];
|
||||
}
|
||||
|
||||
function formatEntryAmount(value: number, code = currency.value) {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
minimumFractionDigits: currencyMinorUnits(code),
|
||||
maximumFractionDigits: currencyMinorUnits(code),
|
||||
}).format(value / 10 ** currencyMinorUnits(code));
|
||||
}
|
||||
|
||||
function formatShortDate(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function openReimbursement() {
|
||||
if (!entry.value || !canRecordReimbursement.value) return;
|
||||
const minorUnits = currencyMinorUnits(entry.value.currency);
|
||||
reimbursementAmount.value = (remainingReimbursement.value / 10 ** minorUnits).toFixed(minorUnits);
|
||||
reimbursementOccurredAt.value = toLocalDateTime(new Date());
|
||||
reimbursementNote.value = "";
|
||||
errorMessage.value = "";
|
||||
reimbursementOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveReimbursement() {
|
||||
if (!entry.value || reimbursementSaving.value) return;
|
||||
const numericAmount = Number(reimbursementAmount.value.replaceAll(",", ""));
|
||||
const minorAmount = Math.round(numericAmount * 10 ** currencyMinorUnits(entry.value.currency));
|
||||
const occurredDate = new Date(reimbursementOccurredAt.value);
|
||||
if (
|
||||
!Number.isFinite(numericAmount)
|
||||
|| minorAmount <= 0
|
||||
|| minorAmount > remainingReimbursement.value
|
||||
) {
|
||||
errorMessage.value = "报销金额应大于 0 且不超过剩余可报销金额";
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(occurredDate.getTime())) {
|
||||
errorMessage.value = "请选择报销到账时间";
|
||||
return;
|
||||
}
|
||||
|
||||
reimbursementSaving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await store.addEntry(createEntry({
|
||||
ledgerIds: [...entry.value.ledgerIds],
|
||||
type: "income",
|
||||
amount: minorAmount,
|
||||
currency: entry.value.currency,
|
||||
categoryId: entry.value.categoryId,
|
||||
note: reimbursementNote.value.trim() || `${entry.value.note || selectedCategory.value?.label || "支出"}报销`,
|
||||
occurredAt: occurredDate.toISOString(),
|
||||
createdBy: authStore.user?.id,
|
||||
reimbursementOfEntryId: entry.value.id,
|
||||
}));
|
||||
reimbursementOpen.value = false;
|
||||
savedToast.value = true;
|
||||
window.setTimeout(() => (savedToast.value = false), 1600);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "记录报销失败";
|
||||
} finally {
|
||||
reimbursementSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEntry() {
|
||||
if (!entry.value || saving.value) return;
|
||||
const numericAmount = Number(amount.value.replaceAll(",", ""));
|
||||
|
|
@ -190,7 +303,9 @@ async function saveEntry() {
|
|||
amount: minorAmount,
|
||||
currency: currency.value,
|
||||
ledgerIds: [...ledgerIds.value],
|
||||
categoryId: childCategoryId.value || parentCategoryId.value || entryType.value,
|
||||
categoryId: reimbursementEntry.value
|
||||
? entry.value.categoryId
|
||||
: childCategoryId.value || parentCategoryId.value || entryType.value,
|
||||
note: note.value.trim(),
|
||||
occurredAt: occurredDate.toISOString(),
|
||||
});
|
||||
|
|
@ -207,11 +322,19 @@ async function saveEntry() {
|
|||
|
||||
async function deleteEntry() {
|
||||
if (!entry.value) return;
|
||||
if (entry.value.type === "expense" && expenseReimbursements.value.length) {
|
||||
errorMessage.value = "存在报销记录,请先删除关联的报销到账";
|
||||
return;
|
||||
}
|
||||
const count = ledgerIds.value.length;
|
||||
const message = `确定从这笔账目关联的全部账本中删除吗?\n\n共 ${count} 个账本,删除后无法恢复。`;
|
||||
if (!window.confirm(message)) return;
|
||||
await store.deleteEntry(entry.value.id);
|
||||
await router.back();
|
||||
try {
|
||||
await store.deleteEntry(entry.value.id);
|
||||
await router.back();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "删除失败";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -237,15 +360,16 @@ async function deleteEntry() {
|
|||
<div
|
||||
class="entry-detail-category-mark"
|
||||
:style="{
|
||||
color: selectedCategory?.color ?? typeOption.color,
|
||||
background: selectedCategory?.tint ?? typeOption.tint,
|
||||
color: reimbursementEntry ? '#287c70' : selectedCategory?.color ?? typeOption.color,
|
||||
background: reimbursementEntry ? '#e8f6f2' : selectedCategory?.tint ?? typeOption.tint,
|
||||
}"
|
||||
>
|
||||
<component :is="selectedCategory?.icon ?? typeOption.icon" :size="27" />
|
||||
<RotateCcw v-if="reimbursementEntry" :size="27" />
|
||||
<component v-else :is="selectedCategory?.icon ?? typeOption.icon" :size="27" />
|
||||
</div>
|
||||
<span class="entry-type-label">{{ typeOption.label }}</span>
|
||||
<span class="entry-type-label">{{ reimbursementEntry ? "报销到账" : typeOption.label }}</span>
|
||||
<label class="entry-amount-input">
|
||||
<button type="button" :aria-label="`切换币种,当前${currencies[currency].name}`" @click="currencyPickerOpen = true">
|
||||
<button type="button" :disabled="reimbursementLocked" :aria-label="`切换币种,当前${currencies[currency].name}`" @click="currencyPickerOpen = true">
|
||||
{{ currencySymbol }}
|
||||
</button>
|
||||
<input v-model="amount" inputmode="decimal" aria-label="金额" />
|
||||
|
|
@ -254,7 +378,7 @@ async function deleteEntry() {
|
|||
</section>
|
||||
|
||||
<section class="entry-edit-fields" aria-label="编辑条目">
|
||||
<button class="entry-edit-row" type="button" @click="categorySheetMode = 'parent'">
|
||||
<button class="entry-edit-row" type="button" :disabled="reimbursementLocked" @click="categorySheetMode = 'parent'">
|
||||
<span class="entry-field-icon category"><WalletCards :size="19" /></span>
|
||||
<span class="entry-field-copy"><small>大类</small><strong>{{ selectedParent?.label ?? "请选择" }}</strong></span>
|
||||
<ChevronRight :size="19" />
|
||||
|
|
@ -263,7 +387,7 @@ async function deleteEntry() {
|
|||
<button
|
||||
class="entry-edit-row"
|
||||
type="button"
|
||||
:disabled="!childCategories.length"
|
||||
:disabled="reimbursementLocked || !childCategories.length"
|
||||
@click="categorySheetMode = 'child'"
|
||||
>
|
||||
<span class="entry-field-icon subcategory">
|
||||
|
|
@ -291,7 +415,7 @@ async function deleteEntry() {
|
|||
tabindex="-1"
|
||||
/>
|
||||
|
||||
<button class="entry-edit-row ledger-associations" type="button" @click="ledgerPickerOpen = true">
|
||||
<button class="entry-edit-row ledger-associations" type="button" :disabled="reimbursementLocked" @click="ledgerPickerOpen = true">
|
||||
<span class="entry-field-icon ledger"><BookOpen :size="19" /></span>
|
||||
<span class="entry-field-copy"><small>关联账本 · {{ ledgerIds.length }} 个</small><strong>{{ ledgerLabel }}</strong></span>
|
||||
<ChevronRight :size="18" />
|
||||
|
|
@ -311,6 +435,39 @@ async function deleteEntry() {
|
|||
</label>
|
||||
</section>
|
||||
|
||||
<section v-if="entry.type === 'expense'" class="entry-reimbursement-card">
|
||||
<header>
|
||||
<span class="entry-field-icon reimbursement"><RotateCcw :size="19" /></span>
|
||||
<div><strong>报销</strong><small>{{ expenseReimbursements.length ? `${expenseReimbursements.length} 笔到账` : "尚未报销" }}</small></div>
|
||||
</header>
|
||||
<div v-if="expenseReimbursements.length" class="entry-reimbursement-math">
|
||||
<span><small>原支出</small><strong>{{ formatEntryAmount(entry.amount, entry.currency) }}</strong></span>
|
||||
<span><small>已报销</small><strong>− {{ formatEntryAmount(reimbursedAmount, entry.currency) }}</strong></span>
|
||||
<span><small>净支出</small><strong>{{ formatEntryAmount(netExpenseAmount, entry.currency) }}</strong></span>
|
||||
</div>
|
||||
<div v-if="expenseReimbursements.length" class="entry-reimbursement-list">
|
||||
<RouterLink v-for="item in expenseReimbursements" :key="item.id" :to="`/entries/${item.id}`">
|
||||
<span><RotateCcw :size="15" />{{ formatShortDate(item.occurredAt) }}</span>
|
||||
<strong>+ {{ formatEntryAmount(item.amount, item.currency) }}</strong>
|
||||
<ChevronRight :size="16" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<button v-if="canRecordReimbursement" class="entry-reimbursement-action" type="button" @click="openReimbursement">
|
||||
<RotateCcw :size="17" />记录报销
|
||||
</button>
|
||||
<p v-else-if="remainingReimbursement === 0">已全额报销</p>
|
||||
</section>
|
||||
|
||||
<RouterLink v-else-if="reimbursementEntry && sourceExpense" class="entry-reimbursement-card source" :to="`/entries/${sourceExpense.id}`">
|
||||
<span class="entry-field-icon reimbursement"><RotateCcw :size="19" /></span>
|
||||
<div>
|
||||
<small>关联原支出</small>
|
||||
<strong>{{ sourceExpense.note || findCategory(sourceExpense.categoryId)?.label || "支出" }}</strong>
|
||||
</div>
|
||||
<span>{{ formatEntryAmount(sourceExpense.amount, sourceExpense.currency) }}</span>
|
||||
<ChevronRight :size="17" />
|
||||
</RouterLink>
|
||||
|
||||
<p class="entry-created-at">
|
||||
<span v-if="store.isEntryPending(entry.id)" class="entry-sync-pending" role="img" aria-label="未同步云端" title="未同步云端"><CloudOff :size="13" /></span>
|
||||
由{{ ledgerStore.memberName(entry.createdBy) }}创建 · {{ createdAtLabel }}
|
||||
|
|
@ -399,6 +556,29 @@ async function deleteEntry() {
|
|||
</section>
|
||||
</div>
|
||||
|
||||
<Transition name="sheet">
|
||||
<div v-if="reimbursementOpen" class="entry-category-layer reimbursement-layer">
|
||||
<button class="entry-category-scrim" type="button" aria-label="关闭记录报销" @click="reimbursementOpen = false"></button>
|
||||
<form class="reimbursement-sheet" @submit.prevent="saveReimbursement">
|
||||
<div class="entry-sheet-handle"></div>
|
||||
<header>
|
||||
<div><strong>记录报销</strong><small>剩余 {{ entry ? formatEntryAmount(remainingReimbursement, entry.currency) : "" }}</small></div>
|
||||
<button type="button" aria-label="关闭" title="关闭" @click="reimbursementOpen = false"><X :size="20" /></button>
|
||||
</header>
|
||||
<label class="reimbursement-amount">
|
||||
<span>{{ entry ? currencies[entry.currency].symbol : "¥" }}</span>
|
||||
<input v-model="reimbursementAmount" inputmode="decimal" aria-label="本次报销金额" autofocus />
|
||||
</label>
|
||||
<label><span>到账时间</span><input v-model="reimbursementOccurredAt" type="datetime-local" /></label>
|
||||
<label><span>备注</span><input v-model="reimbursementNote" maxlength="120" placeholder="补充说明" /></label>
|
||||
<p v-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||
<button class="reimbursement-submit" type="submit" :disabled="reimbursementSaving">
|
||||
<Check :size="18" />{{ reimbursementSaving ? "保存中" : "确认报销" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="toast">
|
||||
<div v-if="savedToast" class="entry-detail-toast" role="status"><Check :size="17" />修改已保存</div>
|
||||
</Transition>
|
||||
|
|
@ -876,6 +1056,254 @@ async function deleteEntry() {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-field-icon.reimbursement {
|
||||
background: #e8f6f2;
|
||||
color: #287c70;
|
||||
}
|
||||
|
||||
.entry-reimbursement-card {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #dce7e4;
|
||||
border-bottom: 1px solid #dce7e4;
|
||||
padding: 14px 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.entry-reimbursement-card > header {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.entry-reimbursement-card > header div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.entry-reimbursement-card > header small,
|
||||
.entry-reimbursement-card.source small {
|
||||
color: #7b8884;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.entry-reimbursement-math {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 13px;
|
||||
border: 1px solid #e1ebe8;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfa;
|
||||
}
|
||||
|
||||
.entry-reimbursement-math span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.entry-reimbursement-math span + span {
|
||||
border-left: 1px solid #e1ebe8;
|
||||
}
|
||||
|
||||
.entry-reimbursement-math small {
|
||||
color: #7b8884;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.entry-reimbursement-math strong {
|
||||
overflow: hidden;
|
||||
color: #31504a;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry-reimbursement-math span:nth-child(2) strong {
|
||||
color: #287c70;
|
||||
}
|
||||
|
||||
.entry-reimbursement-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.entry-reimbursement-list a {
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto 18px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-bottom: 1px solid #edf2f0;
|
||||
color: #536762;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.entry-reimbursement-list a span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.entry-reimbursement-list a strong {
|
||||
color: #287c70;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.entry-reimbursement-action {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
margin-top: 12px;
|
||||
border: 1px solid #9bcfc3;
|
||||
border-radius: 8px;
|
||||
background: #edf8f5;
|
||||
color: #176d61;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.entry-reimbursement-card > p {
|
||||
margin: 12px 0 0;
|
||||
color: #287c70;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.entry-reimbursement-card.source {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto 18px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #27342f;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.entry-reimbursement-card.source > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.entry-reimbursement-card.source > span:not(.entry-field-icon) {
|
||||
color: #687873;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.reimbursement-sheet {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
gap: 13px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 8px 16px calc(16px + env(safe-area-inset-bottom));
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -14px 34px rgba(22, 50, 44, 0.16);
|
||||
}
|
||||
|
||||
.reimbursement-sheet > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reimbursement-sheet > header div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.reimbursement-sheet > header small {
|
||||
color: #7b8884;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.reimbursement-sheet > header button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #f0f5f3;
|
||||
color: #536762;
|
||||
}
|
||||
|
||||
.reimbursement-sheet > label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #687873;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.reimbursement-sheet > label input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid #d8e5e1;
|
||||
border-radius: 8px;
|
||||
padding: 0 11px;
|
||||
outline: none;
|
||||
background: #fbfdfc;
|
||||
color: #27342f;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reimbursement-sheet > label input:focus {
|
||||
border-color: #4b9e8e;
|
||||
box-shadow: 0 0 0 3px rgba(75, 158, 142, 0.12);
|
||||
}
|
||||
|
||||
.reimbursement-sheet .reimbursement-amount {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #287c70;
|
||||
}
|
||||
|
||||
.reimbursement-amount > span {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
z-index: 1;
|
||||
font-size: 25px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.reimbursement-sheet .reimbursement-amount input {
|
||||
height: 62px;
|
||||
padding-left: 44px;
|
||||
color: #287c70;
|
||||
font-size: 30px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.reimbursement-sheet > p {
|
||||
margin: -4px 0;
|
||||
color: #c94635;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.reimbursement-submit {
|
||||
min-height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #287c70;
|
||||
color: #ffffff;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.reimbursement-submit:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.entry-detail-toast {
|
||||
position: absolute;
|
||||
right: 50%;
|
||||
|
|
@ -919,7 +1347,9 @@ async function deleteEntry() {
|
|||
}
|
||||
|
||||
.sheet-enter-active .entry-category-sheet,
|
||||
.sheet-leave-active .entry-category-sheet {
|
||||
.sheet-leave-active .entry-category-sheet,
|
||||
.sheet-enter-active .reimbursement-sheet,
|
||||
.sheet-leave-active .reimbursement-sheet {
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
|
||||
|
|
@ -929,7 +1359,9 @@ async function deleteEntry() {
|
|||
}
|
||||
|
||||
.sheet-enter-from .entry-category-sheet,
|
||||
.sheet-leave-to .entry-category-sheet {
|
||||
.sheet-leave-to .entry-category-sheet,
|
||||
.sheet-enter-from .reimbursement-sheet,
|
||||
.sheet-leave-to .reimbursement-sheet {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
Filter,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Share2,
|
||||
X,
|
||||
|
|
@ -26,6 +27,13 @@ import { apiRequest, ApiError } from "../data/api";
|
|||
import { categories, entryTypes, findCategory, findCategoryPath } from "../data/categories";
|
||||
import { ledgerIconComponent } from "../data/ledger-icons";
|
||||
import { ledgerTheme } from "../data/ledgers";
|
||||
import {
|
||||
isReimbursement,
|
||||
netBaseAmount,
|
||||
netOriginalAmount,
|
||||
reimbursedBaseAmount,
|
||||
reimbursedOriginalAmount,
|
||||
} from "../data/reimbursements";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
|
|
@ -152,9 +160,9 @@ const dailyTotals = computed(() => {
|
|||
for (const entry of monthlyEntries.value) {
|
||||
const key = localDateKey(entry.occurredAt);
|
||||
const summary = summaries.get(key) ?? { income: 0, expense: 0 };
|
||||
if (entry.baseAmount === null) continue;
|
||||
if (isReimbursement(entry) || entry.baseAmount === null) continue;
|
||||
if (entry.type === "income") summary.income += entry.baseAmount;
|
||||
else summary.expense += entry.baseAmount;
|
||||
else summary.expense += netBaseAmount(entry, store.entries) ?? 0;
|
||||
summaries.set(key, summary);
|
||||
}
|
||||
return summaries;
|
||||
|
|
@ -187,23 +195,38 @@ const headerEntries = computed(() => {
|
|||
const headerSummaryLabel = computed(() => ledgerStore.currentLedger?.summaryRange === "ledger" ? "账本结余" : "当年结余");
|
||||
|
||||
const summaryCurrencySlides = computed(() => {
|
||||
const summaries = new Map<LedgerEntry["currency"], { income: number; expense: number }>();
|
||||
const summaries = new Map<LedgerEntry["currency"], {
|
||||
income: number;
|
||||
grossExpense: number;
|
||||
reimbursement: number;
|
||||
}>();
|
||||
for (const entry of headerEntries.value) {
|
||||
if (isReimbursement(entry)) continue;
|
||||
const currency = showBaseCurrency.value ? "CNY" : entry.currency;
|
||||
const amount = showBaseCurrency.value ? entry.baseAmount : entry.amount;
|
||||
if (amount === null) continue;
|
||||
const summary = summaries.get(currency) ?? { income: 0, expense: 0 };
|
||||
const summary = summaries.get(currency) ?? { income: 0, grossExpense: 0, reimbursement: 0 };
|
||||
if (entry.type === "income") summary.income += amount;
|
||||
else summary.expense += amount;
|
||||
else {
|
||||
summary.grossExpense += amount;
|
||||
summary.reimbursement += showBaseCurrency.value
|
||||
? reimbursedBaseAmount(entry, store.entries) ?? 0
|
||||
: reimbursedOriginalAmount(entry, store.entries);
|
||||
}
|
||||
summaries.set(currency, summary);
|
||||
}
|
||||
if (!summaries.size) summaries.set("CNY", { income: 0, expense: 0 });
|
||||
return [...summaries.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([currency, summary]) => ({
|
||||
currency,
|
||||
balance: formatCurrencyAmount(summary.income - summary.expense, currency),
|
||||
income: formatCurrencyAmount(summary.income, currency),
|
||||
expense: formatCurrencyAmount(summary.expense, currency),
|
||||
}));
|
||||
if (!summaries.size) summaries.set("CNY", { income: 0, grossExpense: 0, reimbursement: 0 });
|
||||
return [...summaries.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([currency, summary]) => {
|
||||
const expense = Math.max(0, summary.grossExpense - summary.reimbursement);
|
||||
return {
|
||||
currency,
|
||||
balance: formatCurrencyAmount(summary.income - expense, currency),
|
||||
income: formatCurrencyAmount(summary.income, currency),
|
||||
expense: formatCurrencyAmount(expense, currency),
|
||||
reimbursement: formatCurrencyAmount(summary.reimbursement, currency, false),
|
||||
hasReimbursement: summary.reimbursement > 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
watch(summaryCurrencySlides, (slides) => {
|
||||
|
|
@ -218,15 +241,17 @@ function summarizeEntries(entries: LedgerEntry[]) {
|
|||
const balanceByCurrency = new Map<LedgerEntry["currency"], number>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (isReimbursement(entry)) continue;
|
||||
if (showBaseCurrency.value) {
|
||||
if (entry.baseAmount === null) continue;
|
||||
if (entry.type === "income") income += entry.baseAmount;
|
||||
else expense += entry.baseAmount;
|
||||
else expense += netBaseAmount(entry, store.entries) ?? 0;
|
||||
continue;
|
||||
}
|
||||
const totalsByCurrency = entry.type === "income" ? incomeByCurrency : expenseByCurrency;
|
||||
totalsByCurrency.set(entry.currency, (totalsByCurrency.get(entry.currency) ?? 0) + entry.amount);
|
||||
balanceByCurrency.set(entry.currency, (balanceByCurrency.get(entry.currency) ?? 0) + (entry.type === "income" ? entry.amount : -entry.amount));
|
||||
const amount = entry.type === "expense" ? netOriginalAmount(entry, store.entries) : entry.amount;
|
||||
totalsByCurrency.set(entry.currency, (totalsByCurrency.get(entry.currency) ?? 0) + amount);
|
||||
balanceByCurrency.set(entry.currency, (balanceByCurrency.get(entry.currency) ?? 0) + (entry.type === "income" ? amount : -amount));
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -635,14 +660,37 @@ function formatTime(value: string) {
|
|||
}
|
||||
|
||||
function categoryLabel(entry: LedgerEntry) {
|
||||
if (isReimbursement(entry)) {
|
||||
const source = store.entries.find((item) => item.id === entry.reimbursementOfEntryId);
|
||||
const sourcePath = source ? findCategoryPath("expense", source.categoryId) : [];
|
||||
return `报销${sourcePath.length ? ` · ${sourcePath.at(-1)!.label}` : ""}`;
|
||||
}
|
||||
const path = findCategoryPath(entry.type, entry.categoryId);
|
||||
return path.map((item) => item.label).join(" · ") || (entry.type === "expense" ? "支出" : "收入");
|
||||
}
|
||||
|
||||
function categoryIcon(entry: LedgerEntry) {
|
||||
if (isReimbursement(entry)) return RotateCcw;
|
||||
return findCategory(entry.categoryId)?.icon ?? entryTypes.find((type) => type.id === entry.type)?.icon ?? BookOpen;
|
||||
}
|
||||
|
||||
function entryHasReimbursement(entry: LedgerEntry) {
|
||||
return entry.type === "expense" && reimbursedOriginalAmount(entry, store.entries) > 0;
|
||||
}
|
||||
|
||||
function entryDisplayAmount(entry: LedgerEntry, net = false) {
|
||||
if (showBaseCurrency.value && entry.baseAmount !== null) {
|
||||
const amount = net && entry.type === "expense"
|
||||
? netBaseAmount(entry, store.entries) ?? entry.baseAmount
|
||||
: entry.baseAmount;
|
||||
return `¥${formatMoney(amount)}`;
|
||||
}
|
||||
const amount = net && entry.type === "expense"
|
||||
? netOriginalAmount(entry, store.entries)
|
||||
: entry.amount;
|
||||
return formatCurrencyAmount(amount, entry.currency);
|
||||
}
|
||||
|
||||
function openParticipantSheet() {
|
||||
participantOpen.value = true;
|
||||
void ledgerStore.loadCurrentMembers();
|
||||
|
|
@ -783,7 +831,10 @@ async function shareLedger() {
|
|||
<span>支出</span>
|
||||
<div class="summary-value-window">
|
||||
<div class="summary-value-track" :style="{ '--summary-slides': summaryCurrencySlides.length, transform: `translate3d(-${summaryCurrencyIndex * (100 / summaryCurrencySlides.length)}%, 0, 0)` }">
|
||||
<strong v-for="slide in summaryCurrencySlides" :key="slide.currency" class="summary-value-slide" :class="{ compact: slide.expense.length > 10 }">{{ slide.expense }}</strong>
|
||||
<div v-for="slide in summaryCurrencySlides" :key="slide.currency" class="summary-value-slide summary-expense-slide">
|
||||
<strong :class="{ compact: slide.expense.length > 10 }">{{ slide.expense }}</strong>
|
||||
<small v-if="slide.hasReimbursement">已减去报销金额 {{ slide.reimbursement }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -932,8 +983,8 @@ async function shareLedger() {
|
|||
<div
|
||||
class="entry-category-icon"
|
||||
:style="{
|
||||
color: findCategory(entry.categoryId)?.color ?? (entry.type === 'expense' ? '#ef5b3f' : '#16966f'),
|
||||
background: findCategory(entry.categoryId)?.tint ?? (entry.type === 'expense' ? '#fff0ec' : '#e9faf4'),
|
||||
color: isReimbursement(entry) ? '#287c70' : findCategory(entry.categoryId)?.color ?? (entry.type === 'expense' ? '#ef5b3f' : '#16966f'),
|
||||
background: isReimbursement(entry) ? '#e8f6f2' : findCategory(entry.categoryId)?.tint ?? (entry.type === 'expense' ? '#fff0ec' : '#e9faf4'),
|
||||
}"
|
||||
>
|
||||
<component :is="categoryIcon(entry)" :size="21" />
|
||||
|
|
@ -947,11 +998,8 @@ async function shareLedger() {
|
|||
</div>
|
||||
</div>
|
||||
<div class="entry-value">
|
||||
<strong :class="entry.type">
|
||||
{{ entry.type === "expense" ? "−" : "+" }}{{ showBaseCurrency && entry.baseAmount !== null
|
||||
? `¥${formatMoney(entry.baseAmount)}`
|
||||
: formatCurrencyAmount(entry.amount, entry.currency) }}
|
||||
</strong>
|
||||
<span v-if="entryHasReimbursement(entry)" class="entry-original-amount">−{{ entryDisplayAmount(entry) }}</span>
|
||||
<strong :class="entry.type">{{ entry.type === "expense" ? "−" : "+" }}{{ entryDisplayAmount(entry, true) }}</strong>
|
||||
<span>{{ formatTime(entry.occurredAt) }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { formatCurrencyAmount, type LedgerEntry } from "@cents/domain";
|
||||
import { ArrowLeft, Check, ChevronDown, ChevronLeft, ChevronRight, CircleUserRound, Filter, Minus, ReceiptText, WalletCards, X } from "@lucide/vue";
|
||||
import { ArrowLeft, Check, ChevronDown, ChevronLeft, ChevronRight, CircleUserRound, Filter, Minus, ReceiptText, RotateCcw, WalletCards, X } from "@lucide/vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
import { categories, entryTypes, findCategory, findCategoryPath } from "../data/categories";
|
||||
import { ledgerIconComponent } from "../data/ledger-icons";
|
||||
import { ledgerTheme } from "../data/ledgers";
|
||||
import { isReimbursement } from "../data/reimbursements";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
|
|
@ -292,11 +293,17 @@ onMounted(async () => {
|
|||
});
|
||||
|
||||
function entryCategoryLabel(entry: LedgerEntry) {
|
||||
if (isReimbursement(entry)) {
|
||||
const source = entryStore.entries.find((item) => item.id === entry.reimbursementOfEntryId);
|
||||
const label = source ? findCategoryPath("expense", source.categoryId).at(-1)?.label : "";
|
||||
return `报销${label ? ` · ${label}` : ""}`;
|
||||
}
|
||||
return findCategoryPath(entry.type, entry.categoryId).map((item) => item.label).join(" · ")
|
||||
|| (entry.type === "expense" ? "支出" : "收入");
|
||||
}
|
||||
|
||||
function entryIcon(entry: LedgerEntry) {
|
||||
if (isReimbursement(entry)) return RotateCcw;
|
||||
return findCategory(entry.categoryId)?.icon ?? entryTypes.find((item) => item.id === entry.type)?.icon ?? ReceiptText;
|
||||
}
|
||||
|
||||
|
|
@ -352,7 +359,7 @@ function localDateKey(value: string) {
|
|||
<section v-for="group in groupedEntries" :key="group.date" class="stats-entries-day">
|
||||
<header><strong>{{ dayLabel(group.date) }}</strong><span>{{ group.entries.length }} 笔</span></header>
|
||||
<RouterLink v-for="entry in group.entries" :key="entry.id" :to="`/entries/${entry.id}`" class="stats-entry-row">
|
||||
<span class="stats-entry-icon" :style="{ color: findCategory(entry.categoryId)?.color ?? '#087f72', background: findCategory(entry.categoryId)?.tint ?? '#e9f7f3' }"><component :is="entryIcon(entry)" :size="19" /></span>
|
||||
<span class="stats-entry-icon" :style="{ color: isReimbursement(entry) ? '#287c70' : findCategory(entry.categoryId)?.color ?? '#087f72', background: isReimbursement(entry) ? '#e8f6f2' : findCategory(entry.categoryId)?.tint ?? '#e9f7f3' }"><component :is="entryIcon(entry)" :size="19" /></span>
|
||||
<span class="stats-entry-copy"><strong>{{ entry.note || entryCategoryLabel(entry) }}</strong><small><CircleUserRound :size="12" />{{ entryCategoryLabel(entry) }}</small></span>
|
||||
<strong class="stats-entry-amount" :class="entry.type">{{ entryAmount(entry) }}</strong>
|
||||
</RouterLink>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
|
|||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
import { entryTypes, findCategoryPath } from "../data/categories";
|
||||
import { ledgerTheme } from "../data/ledgers";
|
||||
import { isReimbursement, netBaseAmount } from "../data/reimbursements";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
|
|
@ -174,8 +175,9 @@ const entries = computed(() => {
|
|||
});
|
||||
|
||||
const totals = computed(() => summarize(entries.value));
|
||||
const convertedEntries = computed(() => entries.value.filter((entry) => entry.baseAmount !== null));
|
||||
const pendingConversionCount = computed(() => entries.value.length - convertedEntries.value.length);
|
||||
const statsEntries = computed(() => entries.value.filter((entry) => !isReimbursement(entry)));
|
||||
const convertedEntries = computed(() => statsEntries.value.filter((entry) => entry.baseAmount !== null));
|
||||
const pendingConversionCount = computed(() => statsEntries.value.length - convertedEntries.value.length);
|
||||
const calendarCursorMonth = ref(selectedMonth.value);
|
||||
const calendarCursorYear = ref(selectedYear.value);
|
||||
|
||||
|
|
@ -358,6 +360,7 @@ const expandedCategoryKeys = ref(new Set<string>());
|
|||
const categoryTree = computed(() => {
|
||||
const roots = new Map<string, CategorySummaryNode>();
|
||||
for (const entry of convertedEntries.value) {
|
||||
const amount = entry.type === "expense" ? netBaseAmount(entry, entryStore.entries) ?? 0 : entry.baseAmount!;
|
||||
const type = entryTypes.find((item) => item.id === entry.type);
|
||||
if (!type) continue;
|
||||
const root = roots.get(entry.type) ?? {
|
||||
|
|
@ -370,7 +373,7 @@ const categoryTree = computed(() => {
|
|||
count: 0,
|
||||
children: [],
|
||||
};
|
||||
root.amount += entry.baseAmount!;
|
||||
root.amount += amount;
|
||||
root.count += 1;
|
||||
|
||||
const path = findCategoryPath(entry.type, entry.categoryId);
|
||||
|
|
@ -395,7 +398,7 @@ const categoryTree = computed(() => {
|
|||
};
|
||||
root.children.push(group);
|
||||
}
|
||||
group.amount += entry.baseAmount!;
|
||||
group.amount += amount;
|
||||
group.count += 1;
|
||||
|
||||
const leafCategory = path[1];
|
||||
|
|
@ -414,7 +417,7 @@ const categoryTree = computed(() => {
|
|||
};
|
||||
group.children.push(leaf);
|
||||
}
|
||||
leaf.amount += entry.baseAmount!;
|
||||
leaf.amount += amount;
|
||||
leaf.count += 1;
|
||||
}
|
||||
roots.set(entry.type, root);
|
||||
|
|
@ -471,8 +474,9 @@ function summarize(items: LedgerEntry[]) {
|
|||
return items.reduce(
|
||||
(result, entry) => {
|
||||
if (entry.baseAmount === null) return result;
|
||||
if (isReimbursement(entry)) return result;
|
||||
if (entry.type === "income") result.income += entry.baseAmount;
|
||||
else result.expense += entry.baseAmount;
|
||||
else result.expense += netBaseAmount(entry, entryStore.entries) ?? 0;
|
||||
return result;
|
||||
},
|
||||
{ income: 0, expense: 0 },
|
||||
|
|
|
|||
254
docs/二期-报销与退款.md
254
docs/二期-报销与退款.md
|
|
@ -1,228 +1,84 @@
|
|||
# 二期:报销与退款
|
||||
# 报销
|
||||
|
||||
## 背景
|
||||
## 产品定义
|
||||
|
||||
家庭账本里有一类收入并不代表真实新增收入,而是对过去支出的抵消,例如:
|
||||
报销是对一笔既有支出的冲减,不属于普通收入。
|
||||
|
||||
- 公司报销餐费、交通费、差旅费。
|
||||
- 商家退款、退货退款、平台补贴返还。
|
||||
- AA 或代付后,其他成员转回款项。
|
||||
|
||||
首版只记录普通收入和支出。二期需要支持“收入关联原支出”,让流水、统计和净支出更接近真实消费。
|
||||
|
||||
## 目标
|
||||
|
||||
- 报销和退款在录入时仍作为收入类型保存。
|
||||
- 输入金额后,系统可以推荐匹配的历史支出。
|
||||
- 用户可以把一笔报销或退款关联到一笔或多笔支出。
|
||||
- 流水中能看出某笔收入是报销或退款,以及它关联了哪些支出。
|
||||
- 月度统计能区分总支出、报销/退款抵扣、净支出。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不做企业报销流程审批。
|
||||
- 不做发票、附件、小票 OCR。
|
||||
- 不做复杂应收应付和债务结算。
|
||||
- 不自动修改原支出金额。
|
||||
- 不要求系统自动判断 100% 正确,必须允许用户手动选择或取消关联。
|
||||
|
||||
## 概念定义
|
||||
|
||||
### 普通收入
|
||||
|
||||
真实增加可支配金额的收入,例如工资、奖金、利息。
|
||||
|
||||
### 报销
|
||||
|
||||
对已发生支出的补偿。通常来自公司、组织或共同生活成员。
|
||||
|
||||
示例:用户先记一笔 `交通 120 元`,之后收到公司报销 `120 元`。
|
||||
|
||||
### 退款
|
||||
|
||||
商家、平台或交易对方退回的金额。通常对应购物、服务、押金等历史支出。
|
||||
|
||||
示例:用户先记一笔 `购物 299 元`,之后退货收到 `299 元`。
|
||||
|
||||
### 关联支出
|
||||
|
||||
被报销或退款收入抵消的原始支出记录。
|
||||
- 报销只能从支出详情发起,不进入快速记账的收入分类。
|
||||
- 一笔报销只关联一笔原支出。
|
||||
- 一笔支出可以分多次报销,也可以只报销一部分。
|
||||
- 累计报销金额不能超过原支出金额。
|
||||
- 首版不实现报销单、审批、发票和一笔到账分摊多笔支出。
|
||||
|
||||
## 录入流程
|
||||
|
||||
### 快速记账入口
|
||||
1. 用户进入一笔支出的详情。
|
||||
2. 点击“记录报销”。
|
||||
3. 输入本次报销金额、到账时间和备注。
|
||||
4. 币种、分类和账本自动继承原支出,不能单独修改。
|
||||
5. 保存后生成一条独立的报销到账流水。
|
||||
|
||||
收入类型下增加收入子类型:
|
||||
只有原支出的所有者可以记录报销。共享账本成员可以查看报销及净支出,但不能替其他用户操作报销。
|
||||
|
||||
- 普通收入
|
||||
- 报销
|
||||
- 退款
|
||||
## 数据模型
|
||||
|
||||
用户选择 `报销` 或 `退款` 后:
|
||||
`entries` 增加自关联字段:
|
||||
|
||||
1. 输入收入金额。
|
||||
2. 系统根据金额、时间、分类、备注、付款人推荐历史支出。
|
||||
3. 用户选择一笔或多笔支出。
|
||||
4. 用户确认保存。
|
||||
5. 新收入记录保存,并建立与原支出的关联。
|
||||
```text
|
||||
reimbursement_of_entry_id
|
||||
```
|
||||
|
||||
### 推荐匹配
|
||||
- 普通收入:`type = income`,关联字段为空。
|
||||
- 报销到账:`type = income`,关联字段指向原支出。
|
||||
- 原支出:`type = expense`。
|
||||
|
||||
默认推荐范围:
|
||||
一笔支出的已报销金额等于所有未删除关联流水的金额之和。报销状态由金额计算,不额外存储:
|
||||
|
||||
- 同一账本内。
|
||||
- 未删除支出。
|
||||
- 发生时间早于或等于当前报销/退款时间。
|
||||
- 最近 180 天内优先。
|
||||
- 金额相同或接近优先。
|
||||
- 未报销:已报销金额为零。
|
||||
- 部分报销:已报销金额小于原支出。
|
||||
- 全额报销:已报销金额等于原支出。
|
||||
|
||||
推荐排序建议:
|
||||
|
||||
1. 金额完全相同。
|
||||
2. 金额差额较小。
|
||||
3. 日期更近。
|
||||
4. 分类更相关。
|
||||
5. 备注文本有相似词。
|
||||
6. 同一付款人。
|
||||
|
||||
### 手动关联
|
||||
|
||||
用户必须能:
|
||||
|
||||
- 搜索历史支出。
|
||||
- 改选推荐结果。
|
||||
- 选择多笔支出。
|
||||
- 不关联任何支出,先保存为未关联报销/退款。
|
||||
- 保存后再补充或修改关联。
|
||||
|
||||
## 金额规则
|
||||
|
||||
一笔报销或退款可以关联:
|
||||
|
||||
- 一笔支出。
|
||||
- 多笔支出。
|
||||
- 一笔支出的一部分金额。
|
||||
|
||||
关联金额需要单独记录,不能只靠收入金额和支出金额推导。
|
||||
|
||||
示例:
|
||||
|
||||
- 支出 `餐饮 100 元`。
|
||||
- 报销 `80 元`。
|
||||
- 关联金额为 `80 元`,原支出仍显示 `100 元`。
|
||||
|
||||
多笔关联示例:
|
||||
|
||||
- 支出 A `交通 60 元`。
|
||||
- 支出 B `餐饮 40 元`。
|
||||
- 报销 `100 元`。
|
||||
- 报销记录同时关联 A 和 B。
|
||||
|
||||
## 统计规则
|
||||
|
||||
二期统计至少区分:
|
||||
|
||||
- 总收入:包含普通收入、报销、退款。
|
||||
- 普通收入:只包含真实收入。
|
||||
- 总支出:原始支出合计,不被报销/退款改写。
|
||||
- 报销/退款抵扣:已关联到支出的报销和退款金额。
|
||||
- 净支出:总支出减去报销/退款抵扣。
|
||||
|
||||
首选展示方式:
|
||||
|
||||
- 流水仍显示原始收入和支出,保持账目真实发生。
|
||||
- 汇总页突出 `净支出`。
|
||||
- 报销和退款作为收入展示,但使用不同标签,避免被误认为工资等普通收入。
|
||||
存在报销记录时不能删除原支出。原支出仍可修改备注、时间或增加金额,但不能改分类、币种和账本,也不能把金额降到累计报销金额以下。
|
||||
|
||||
## 流水展示
|
||||
|
||||
报销/退款收入行应显示:
|
||||
发生报销的原支出保留原始金额,并显示冲减后的净支出:
|
||||
|
||||
- 金额为正数。
|
||||
- 类型标签:报销或退款。
|
||||
- 关联状态:已关联、部分关联、未关联。
|
||||
- 关联的原支出摘要。
|
||||
```text
|
||||
原金额 ¥3,200(划线)
|
||||
净支出 ¥3,000
|
||||
```
|
||||
|
||||
原支出行应显示:
|
||||
报销到账按实际到账日期显示为独立流水,并标记为“报销”。
|
||||
|
||||
- 原始支出金额不变。
|
||||
- 若已有报销/退款抵扣,显示抵扣金额。
|
||||
- 可进入详情查看关联收入。
|
||||
流水页顶部有报销时使用竖式展示支出:
|
||||
|
||||
状态建议:
|
||||
```text
|
||||
支出 ¥3,200
|
||||
- 200
|
||||
─────
|
||||
¥3,000
|
||||
```
|
||||
|
||||
- `未关联`:没有任何关联支出。
|
||||
- `部分抵扣`:关联金额小于原支出金额。
|
||||
- `已抵扣`:关联金额大于或等于原支出金额。
|
||||
没有报销时只显示普通支出金额,不保留竖式空间。
|
||||
|
||||
## 数据模型建议
|
||||
## 统计口径
|
||||
|
||||
### entry 扩展
|
||||
- 收入:只统计普通收入,不包含报销到账。
|
||||
- 原支出:支出的原始金额。
|
||||
- 报销:关联到这些原支出的累计报销金额。
|
||||
- 净支出:原支出减去报销。
|
||||
- 结余:普通收入减去净支出。
|
||||
|
||||
在 `entry` 上增加收入子类型字段:
|
||||
报销按原支出的日期、分类和账本冲减。因此即使报销在下个月到账,也会更新原支出所在期间的净支出统计。
|
||||
|
||||
- `income_kind`:`regular`、`reimbursement`、`refund`。
|
||||
外币报销首版继承原支出币种。本币净支出按照原支出的本币金额比例冲减,保证全额报销后的本币净支出为零。
|
||||
|
||||
约束:
|
||||
## 离线与同步
|
||||
|
||||
- `type = income` 时可填写 `income_kind`。
|
||||
- `type = expense` 时 `income_kind` 为空。
|
||||
- 兼容旧数据时,收入默认视为 `regular`。
|
||||
报销流水与普通账目使用同一套 IndexedDB 和同步操作:
|
||||
|
||||
### entry_link
|
||||
|
||||
新增关联表:
|
||||
|
||||
- `id`:客户端生成 UUID。
|
||||
- `ledger_id`
|
||||
- `source_entry_id`:报销/退款收入。
|
||||
- `target_entry_id`:被关联支出。
|
||||
- `link_type`:`reimbursement` 或 `refund`。
|
||||
- `amount`:本次关联金额,整数分。
|
||||
- `created_by`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `deleted_at`
|
||||
- `version`
|
||||
|
||||
约束:
|
||||
|
||||
- `source_entry_id` 必须指向收入。
|
||||
- `target_entry_id` 必须指向支出。
|
||||
- 两条账目必须属于同一账本。
|
||||
- 关联金额必须大于 0。
|
||||
- 同一收入关联多笔支出的金额合计不应超过收入金额,除非后续明确支持超额报销。
|
||||
|
||||
## 同步与离线
|
||||
|
||||
报销/退款关联也必须离线可用:
|
||||
|
||||
- 新增、修改、删除关联写入本地 IndexedDB。
|
||||
- 每次关联变更生成同步操作。
|
||||
- 服务端按 `operation_id` 幂等处理。
|
||||
- 删除账目时不硬删除关联,使用软删除。
|
||||
|
||||
冲突策略二期先保持简单:
|
||||
|
||||
- 同一条关联被多设备修改时,最后写入胜出。
|
||||
- 如果原支出或收入已删除,关联保留软删除或标记失效。
|
||||
- 汇总统计只计算未删除且有效的关联。
|
||||
|
||||
## 原型需求
|
||||
|
||||
需要补充这些原型:
|
||||
|
||||
- 快速记账收入模式下的 `普通收入 / 报销 / 退款` 切换。
|
||||
- 输入金额后的候选支出匹配列表。
|
||||
- 手动搜索并选择历史支出。
|
||||
- 一笔收入关联多笔支出的选择态。
|
||||
- 流水中报销/退款和原支出的关联展示。
|
||||
- 账目详情中的关联管理。
|
||||
|
||||
## 待决策问题
|
||||
|
||||
- 报销和退款是否共用同一套分类,还是独立于普通收入分类。
|
||||
- 未关联的报销/退款是否计入净支出抵扣。
|
||||
- 关联金额是否允许超过原支出剩余未抵扣金额。
|
||||
- AA 或代付回款是否归入报销,还是后续独立成“代付结算”。
|
||||
- 统计页默认展示总支出还是净支出。
|
||||
- 离线时可以创建、修改和删除报销到账。
|
||||
- 恢复联网后按照操作创建时间同步。
|
||||
- 服务端再次验证原支出所有者、币种、分类和累计报销金额。
|
||||
- 服务端通过行锁避免多个设备同时报销导致累计金额超限。
|
||||
|
|
|
|||
|
|
@ -66,4 +66,4 @@
|
|||
- 导入导出。
|
||||
- 专门的桌面大屏布局。
|
||||
- 分摊和结算。
|
||||
- 报销、退款和收入关联原支出,见 [二期:报销与退款](二期-报销与退款.md)。
|
||||
- 报销从支出详情发起并冲减原支出;退款暂不实现,见 [报销](二期-报销与退款.md)。
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
### 收入
|
||||
|
||||
共 7 个。
|
||||
共 6 个。
|
||||
|
||||
| 顺序 | 分类 | ID |
|
||||
| --- | --- | --- |
|
||||
|
|
@ -40,9 +40,8 @@
|
|||
| 2 | 奖金 | `bonus` |
|
||||
| 3 | 兼职 | `part-time` |
|
||||
| 4 | 投资 | `investment` |
|
||||
| 5 | 报销 | `refund` |
|
||||
| 6 | 其他 | `other-income` |
|
||||
| 7 | 红包 | `received-red-packet` |
|
||||
| 5 | 其他 | `other-income` |
|
||||
| 6 | 红包 | `received-red-packet` |
|
||||
|
||||
## 三级分类
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export type LedgerEntry = {
|
|||
exchangeRateEffectiveDate: string | null;
|
||||
exchangeRateSource: ExchangeRateSource;
|
||||
conversionStatus: ConversionStatus;
|
||||
reimbursementOfEntryId: string | null;
|
||||
categoryId: string;
|
||||
note: string;
|
||||
occurredAt: string;
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||
exchange_rate_effective_date date,
|
||||
exchange_rate_source varchar(8) NOT NULL CHECK (exchange_rate_source IN ('manual', 'system')),
|
||||
conversion_status varchar(8) NOT NULL DEFAULT 'pending',
|
||||
reimbursement_of_entry_id text REFERENCES entries(id) ON DELETE RESTRICT,
|
||||
category_id varchar(100) NOT NULL,
|
||||
note varchar(500) NOT NULL DEFAULT '',
|
||||
occurred_at timestamptz NOT NULL,
|
||||
|
|
@ -132,6 +133,7 @@ ALTER TABLE entries ALTER COLUMN exchange_rate DROP NOT NULL;
|
|||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS exchange_rate_date date;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS exchange_rate_effective_date date;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS conversion_status varchar(8) NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS reimbursement_of_entry_id text REFERENCES entries(id) ON DELETE RESTRICT;
|
||||
UPDATE entries
|
||||
SET base_currency = 'CNY',
|
||||
base_amount = amount,
|
||||
|
|
@ -178,6 +180,12 @@ ALTER TABLE entries ADD CONSTRAINT entries_conversion_values_check
|
|||
OR
|
||||
(conversion_status IN ('exact', 'fallback') AND base_amount > 0 AND exchange_rate IS NOT NULL)
|
||||
);
|
||||
ALTER TABLE entries DROP CONSTRAINT IF EXISTS entries_reimbursement_shape_check;
|
||||
ALTER TABLE entries ADD CONSTRAINT entries_reimbursement_shape_check
|
||||
CHECK (
|
||||
reimbursement_of_entry_id IS NULL
|
||||
OR (type = 'income' AND reimbursement_of_entry_id <> id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_ledgers (
|
||||
entry_id text NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
|
|
@ -208,6 +216,9 @@ ALTER TABLE entries DROP COLUMN IF EXISTS ledger_id;
|
|||
|
||||
CREATE INDEX IF NOT EXISTS entries_owner_occurred
|
||||
ON entries (owner_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS entries_reimbursement_of
|
||||
ON entries (reimbursement_of_entry_id)
|
||||
WHERE reimbursement_of_entry_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS entry_ledgers_ledger
|
||||
ON entry_ledgers (ledger_id, entry_id);
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,12 @@ function validEntry(value: unknown): value is LedgerEntry {
|
|||
&& (entry.conversionStatus === "pending"
|
||||
? entry.baseAmount === null && entry.exchangeRate === null
|
||||
: entry.baseAmount !== null && entry.exchangeRate !== null && entry.exchangeRateEffectiveDate !== null)
|
||||
&& (entry.reimbursementOfEntryId === undefined
|
||||
|| entry.reimbursementOfEntryId === null
|
||||
|| (typeof entry.reimbursementOfEntryId === "string"
|
||||
&& entry.reimbursementOfEntryId.length > 0
|
||||
&& entry.reimbursementOfEntryId.length <= 100
|
||||
&& entry.reimbursementOfEntryId !== entry.id))
|
||||
&& typeof entry.categoryId === "string" && entry.categoryId.length > 0 && entry.categoryId.length <= 100
|
||||
&& typeof entry.note === "string" && entry.note.length <= 500
|
||||
&& validDate(entry.occurredAt) && validDate(entry.createdAt) && validDate(entry.updatedAt)
|
||||
|
|
@ -577,8 +583,12 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
|||
const existing = await client.query<{
|
||||
ownerId: string;
|
||||
canAccess: boolean;
|
||||
type: "expense" | "income";
|
||||
amount: number;
|
||||
currency: CurrencyCode;
|
||||
categoryId: string;
|
||||
ledgerIds: string[];
|
||||
reimbursementOfEntryId: string | null;
|
||||
exchangeRateDate: string;
|
||||
baseAmount: number | null;
|
||||
exchangeRate: string | null;
|
||||
|
|
@ -593,7 +603,14 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
|||
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
|
||||
AND m.user_id = $2 AND m.removed_at IS NULL
|
||||
)) AS "canAccess",
|
||||
e.amount, e.currency, e.exchange_rate_date::text AS "exchangeRateDate",
|
||||
e.type, e.amount, e.currency, e.category_id AS "categoryId",
|
||||
ARRAY(
|
||||
SELECT el.ledger_id FROM entry_ledgers el
|
||||
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
|
||||
ORDER BY el.ledger_id
|
||||
) AS "ledgerIds",
|
||||
e.reimbursement_of_entry_id AS "reimbursementOfEntryId",
|
||||
e.exchange_rate_date::text AS "exchangeRateDate",
|
||||
e.base_amount AS "baseAmount", e.exchange_rate AS "exchangeRate",
|
||||
e.exchange_rate_effective_date::text AS "exchangeRateEffectiveDate",
|
||||
e.exchange_rate_source AS "exchangeRateSource",
|
||||
|
|
@ -629,10 +646,103 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
|||
[entryOwnerId],
|
||||
);
|
||||
if (!personalLedger.rows[0]) throw new Error("用户缺少个人账本");
|
||||
const targetLedgerIds = [...new Set([...operation.ledgerIds, personalLedger.rows[0].id])];
|
||||
|
||||
const entry = operation.payload;
|
||||
const reimbursementOfEntryId = entry.reimbursementOfEntryId === undefined
|
||||
? existing.rows[0]?.reimbursementOfEntryId ?? null
|
||||
: entry.reimbursementOfEntryId;
|
||||
if (
|
||||
existing.rows[0]
|
||||
&& existing.rows[0].reimbursementOfEntryId !== reimbursementOfEntryId
|
||||
) {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(409).send({ error: "不能修改报销关联" });
|
||||
}
|
||||
let targetLedgerIds = [...new Set([...operation.ledgerIds, personalLedger.rows[0].id])];
|
||||
|
||||
if (reimbursementOfEntryId) {
|
||||
const source = await client.query<{
|
||||
id: string;
|
||||
ownerId: string;
|
||||
type: "expense" | "income";
|
||||
amount: number;
|
||||
currency: CurrencyCode;
|
||||
categoryId: string;
|
||||
ledgerIds: string[];
|
||||
deletedAt: Date | null;
|
||||
}>(
|
||||
`SELECT e.id, e.owner_id AS "ownerId", e.type, e.amount, e.currency,
|
||||
e.category_id AS "categoryId", e.deleted_at AS "deletedAt",
|
||||
ARRAY(
|
||||
SELECT el.ledger_id FROM entry_ledgers el
|
||||
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
|
||||
ORDER BY el.ledger_id
|
||||
) AS "ledgerIds"
|
||||
FROM entries e
|
||||
WHERE e.id = $1
|
||||
FOR UPDATE`,
|
||||
[reimbursementOfEntryId],
|
||||
);
|
||||
const expense = source.rows[0];
|
||||
if (
|
||||
!expense
|
||||
|| expense.deletedAt
|
||||
|| expense.ownerId !== user.id
|
||||
|| expense.type !== "expense"
|
||||
) {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(409).send({ error: "关联的原支出无效" });
|
||||
}
|
||||
if (
|
||||
operation.action !== "delete"
|
||||
&& (entry.type !== "income"
|
||||
|| entry.currency !== expense.currency
|
||||
|| entry.categoryId !== expense.categoryId)
|
||||
) {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(409).send({ error: "报销必须继承原支出的币种和分类" });
|
||||
}
|
||||
const reimbursed = await client.query<{ amount: number }>(
|
||||
`SELECT COALESCE(sum(amount), 0)::int AS amount
|
||||
FROM entries
|
||||
WHERE reimbursement_of_entry_id = $1
|
||||
AND id <> $2
|
||||
AND deleted_at IS NULL`,
|
||||
[expense.id, entry.id],
|
||||
);
|
||||
if (operation.action !== "delete" && reimbursed.rows[0]!.amount + entry.amount > expense.amount) {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(409).send({ error: "报销金额超过剩余可报销金额" });
|
||||
}
|
||||
targetLedgerIds = expense.ledgerIds;
|
||||
}
|
||||
|
||||
const existingEntry = existing.rows[0];
|
||||
if (existingEntry?.type === "expense") {
|
||||
const reimbursements = await client.query<{ amount: number }>(
|
||||
`SELECT COALESCE(sum(amount), 0)::int AS amount
|
||||
FROM entries
|
||||
WHERE reimbursement_of_entry_id = $1
|
||||
AND deleted_at IS NULL`,
|
||||
[entry.id],
|
||||
);
|
||||
const reimbursed = reimbursements.rows[0]!.amount;
|
||||
if (reimbursed > 0 && operation.action === "delete") {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(409).send({ error: "存在报销记录,不能删除原支出" });
|
||||
}
|
||||
if (
|
||||
reimbursed > entry.amount
|
||||
|| (reimbursed > 0 && (
|
||||
entry.type !== "expense"
|
||||
|| entry.currency !== existingEntry.currency
|
||||
|| entry.categoryId !== existingEntry.categoryId
|
||||
|| [...existingEntry.ledgerIds].sort().join(",") !== [...targetLedgerIds].sort().join(",")
|
||||
))
|
||||
) {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(409).send({ error: "存在报销时不能缩小支出金额、改变分类、币种或账本" });
|
||||
}
|
||||
}
|
||||
const existingRateDate = existingEntry?.exchangeRateDate?.slice(0, 10);
|
||||
const conversionChanged = !existingEntry
|
||||
|| existingEntry.amount !== entry.amount
|
||||
|
|
@ -665,11 +775,12 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
|||
`INSERT INTO entries (
|
||||
id, owner_id, type, amount, currency, base_currency, base_amount,
|
||||
exchange_rate, exchange_rate_date, exchange_rate_effective_date,
|
||||
exchange_rate_source, conversion_status, category_id, note, occurred_at,
|
||||
exchange_rate_source, conversion_status, reimbursement_of_entry_id,
|
||||
category_id, note, occurred_at,
|
||||
created_by, updated_by, created_at, updated_at, deleted_at, version
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, 'CNY', $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20
|
||||
$14, $15, $16, $17, $18, $19, $20, $21
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
|
|
@ -682,6 +793,7 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
|||
exchange_rate_effective_date = EXCLUDED.exchange_rate_effective_date,
|
||||
exchange_rate_source = EXCLUDED.exchange_rate_source,
|
||||
conversion_status = EXCLUDED.conversion_status,
|
||||
reimbursement_of_entry_id = EXCLUDED.reimbursement_of_entry_id,
|
||||
category_id = EXCLUDED.category_id,
|
||||
note = EXCLUDED.note,
|
||||
occurred_at = EXCLUDED.occurred_at,
|
||||
|
|
@ -695,9 +807,9 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
|||
[
|
||||
entry.id, user.id, entry.type, entry.amount, entry.currency,
|
||||
conversion.baseAmount, conversion.exchangeRate, entry.exchangeRateDate,
|
||||
conversion.effectiveDate, conversion.source, conversion.status, entry.categoryId,
|
||||
entry.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt,
|
||||
entry.deletedAt, entry.version,
|
||||
conversion.effectiveDate, conversion.source, conversion.status, reimbursementOfEntryId,
|
||||
entry.categoryId, entry.note, entry.occurredAt, user.id, user.id, entry.createdAt,
|
||||
entry.updatedAt, entry.deletedAt, entry.version,
|
||||
],
|
||||
);
|
||||
if (operation.action !== "delete" && writeResult.rowCount) {
|
||||
|
|
@ -777,6 +889,7 @@ server.get<{ Querystring: { cursor?: string } }>("/api/sync/pull", async (reques
|
|||
e.exchange_rate_effective_date::text AS "exchangeRateEffectiveDate",
|
||||
e.exchange_rate_source AS "exchangeRateSource",
|
||||
e.conversion_status AS "conversionStatus",
|
||||
e.reimbursement_of_entry_id AS "reimbursementOfEntryId",
|
||||
e.category_id AS "categoryId", e.note, e.occurred_at AS "occurredAt",
|
||||
e.created_by AS "createdBy", e.updated_by AS "updatedBy",
|
||||
e.created_at AS "createdAt", e.updated_at AS "updatedAt",
|
||||
|
|
|
|||
Loading…
Reference in New Issue