421 lines
17 KiB
TypeScript
421 lines
17 KiB
TypeScript
import { defineStore } from "pinia";
|
|
import { calculateCnyAmount, type CurrencyCode, type LedgerEntry, type SyncOperation } from "@cents/domain";
|
|
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";
|
|
|
|
let syncPromise: Promise<void> | null = null;
|
|
let syncTriggersStarted = false;
|
|
let conversionPollTimer: number | null = null;
|
|
let conversionPollAttempts = 0;
|
|
let conversionPollDeadline = 0;
|
|
const conversionPollEntryIds = new Set<string>();
|
|
|
|
function scheduleConversionPoll(entryIds: string[], resetAttempts = true) {
|
|
for (const entryId of entryIds) conversionPollEntryIds.add(entryId);
|
|
if (conversionPollTimer !== null || typeof window === "undefined") return;
|
|
if (resetAttempts) {
|
|
conversionPollAttempts = 0;
|
|
conversionPollDeadline = Date.now() + 60_000;
|
|
}
|
|
const remainingMs = conversionPollDeadline - Date.now();
|
|
if (remainingMs <= 0) {
|
|
conversionPollEntryIds.clear();
|
|
return;
|
|
}
|
|
const delayMs = Math.min(1_500 * 2 ** conversionPollAttempts, remainingMs);
|
|
const poll = async () => {
|
|
conversionPollTimer = null;
|
|
const store = useEntryStore();
|
|
await store.syncEntries();
|
|
await store.refreshLocalEntries();
|
|
const remaining = [...conversionPollEntryIds].filter((entryId) =>
|
|
store.entries.some((entry) => entry.id === entryId && entry.conversionStatus === "pending"),
|
|
);
|
|
conversionPollEntryIds.clear();
|
|
if (!remaining.length || Date.now() >= conversionPollDeadline) return;
|
|
conversionPollAttempts += 1;
|
|
scheduleConversionPoll(remaining, false);
|
|
};
|
|
conversionPollTimer = window.setTimeout(() => void poll(), delayMs);
|
|
}
|
|
|
|
export const useEntryStore = defineStore("entries", {
|
|
state: () => ({
|
|
entries: [] as LedgerEntry[],
|
|
loadedForUserId: "",
|
|
pendingEntryIds: [] as string[],
|
|
pendingEntries: [] as Array<{ id: string; ledgerIds: string[] }>,
|
|
syncing: false,
|
|
syncError: "",
|
|
lastSyncedAt: "" as string,
|
|
}),
|
|
actions: {
|
|
async loadEntries() {
|
|
const auth = useAuthStore();
|
|
if (this.loadedForUserId !== (auth.user?.id ?? "")) {
|
|
this.entries = [];
|
|
this.pendingEntryIds = [];
|
|
this.pendingEntries = [];
|
|
this.loadedForUserId = auth.user?.id ?? "";
|
|
}
|
|
await useLedgerStore().loadLedgers();
|
|
await this.refreshLocalEntries();
|
|
this.startSyncTriggers();
|
|
void this.syncEntries();
|
|
},
|
|
async refreshLocalEntries() {
|
|
const auth = useAuthStore();
|
|
if (!auth.user) {
|
|
this.entries = [];
|
|
this.pendingEntryIds = [];
|
|
this.pendingEntries = [];
|
|
this.loadedForUserId = "";
|
|
return;
|
|
}
|
|
const db = await getUserDb(auth.user.id);
|
|
const allowedLedgerIds = new Set(useLedgerStore().ledgers.map((ledger) => ledger.id));
|
|
const canAccessEntry = (entry: LedgerEntry) =>
|
|
entry.ownerId === auth.user!.id || entry.ledgerIds.some((ledgerId) => allowedLedgerIds.has(ledgerId));
|
|
this.entries = (await db.entries.orderBy("occurredAt").reverse().toArray()).filter(
|
|
(entry) => entry.deletedAt === null && canAccessEntry(entry),
|
|
);
|
|
const pendingOperations = (await db.syncOperations.filter((operation) => operation.syncedAt === null).toArray())
|
|
.filter((operation) =>
|
|
(operation.userId ?? operation.payload.updatedBy) === auth.user!.id,
|
|
);
|
|
this.pendingEntryIds = [...new Set(pendingOperations.map((operation) => operation.entityId))];
|
|
this.pendingEntries = [...new Map(
|
|
pendingOperations.map((operation) => [operation.entityId, { id: operation.entityId, ledgerIds: operation.ledgerIds }]),
|
|
).values()];
|
|
},
|
|
isEntryPending(entryId: string) {
|
|
return this.pendingEntryIds.includes(entryId);
|
|
},
|
|
pendingEntryCount(ledgerId: string) {
|
|
return this.pendingEntries.filter((entry) => entry.ledgerIds.includes(ledgerId)).length;
|
|
},
|
|
startSyncTriggers() {
|
|
if (syncTriggersStarted || typeof window === "undefined") return;
|
|
syncTriggersStarted = true;
|
|
window.addEventListener("online", () => void this.syncEntries());
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "visible") void this.syncEntries();
|
|
});
|
|
window.setInterval(() => void this.syncEntries(), 60_000);
|
|
},
|
|
async syncEntries() {
|
|
const auth = useAuthStore();
|
|
if (!auth.user || (typeof navigator !== "undefined" && !navigator.onLine)) {
|
|
await this.refreshLocalEntries();
|
|
return;
|
|
}
|
|
const userId = auth.user.id;
|
|
const db = await getUserDb(userId);
|
|
if (syncPromise) return syncPromise;
|
|
|
|
syncPromise = (async () => {
|
|
this.syncing = true;
|
|
this.syncError = "";
|
|
try {
|
|
const conversionIds = new Set<string>();
|
|
while (true) {
|
|
const pending = (await db.syncOperations
|
|
.filter((operation) => operation.syncedAt === null)
|
|
.toArray())
|
|
.filter((operation) => (operation.userId ?? operation.payload.updatedBy) === userId)
|
|
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
|
.slice(0, 200);
|
|
if (!pending.length) break;
|
|
for (const operation of pending) {
|
|
if (operation.action !== "delete" && operation.payload.currency !== "CNY" && operation.payload.conversionStatus === "pending") {
|
|
conversionIds.add(operation.entityId);
|
|
}
|
|
}
|
|
const pushed = await apiRequest<{ acceptedOperationIds: string[] }>("/api/sync/push", {
|
|
method: "POST",
|
|
body: { operations: pending },
|
|
});
|
|
if (!pushed.acceptedOperationIds.length) break;
|
|
await db.syncOperations.bulkDelete(pushed.acceptedOperationIds);
|
|
}
|
|
|
|
const metadata = await db.syncMetadata.get(userId);
|
|
let cursor = metadata?.cursor ?? "";
|
|
let hasMore = false;
|
|
do {
|
|
const path = cursor ? `/api/sync/pull?cursor=${encodeURIComponent(cursor)}` : "/api/sync/pull";
|
|
const pulled = await apiRequest<{ entries: LedgerEntry[]; cursor: string; hasMore: boolean }>(path);
|
|
const remainingOperations = await db.syncOperations
|
|
.filter((operation) => operation.syncedAt === null)
|
|
.toArray();
|
|
const locallyChangedIds = new Set(remainingOperations.map((operation) => operation.entityId));
|
|
const safeCloudEntries = pulled.entries.filter(
|
|
(entry) => !locallyChangedIds.has(entry.id) && (entry.ownerId === userId || entry.ledgerIds.length > 0),
|
|
);
|
|
const inaccessibleEntryIds = pulled.entries
|
|
.filter((entry) => !locallyChangedIds.has(entry.id) && entry.ownerId !== userId && entry.ledgerIds.length === 0)
|
|
.map((entry) => entry.id);
|
|
await db.transaction("rw", db.entries, db.syncMetadata, async () => {
|
|
if (safeCloudEntries.length) await db.entries.bulkPut(safeCloudEntries);
|
|
if (inaccessibleEntryIds.length) await db.entries.bulkDelete(inaccessibleEntryIds);
|
|
if (pulled.cursor) {
|
|
await db.syncMetadata.put({ id: userId, cursor: pulled.cursor, updatedAt: new Date().toISOString() });
|
|
}
|
|
});
|
|
cursor = pulled.cursor;
|
|
hasMore = pulled.hasMore;
|
|
} while (hasMore);
|
|
if (conversionIds.size) scheduleConversionPoll([...conversionIds]);
|
|
this.lastSyncedAt = new Date().toISOString();
|
|
} catch (error) {
|
|
this.syncError = error instanceof Error ? error.message : "同步失败";
|
|
} finally {
|
|
await this.refreshLocalEntries();
|
|
this.syncing = false;
|
|
}
|
|
})();
|
|
try {
|
|
await syncPromise;
|
|
} finally {
|
|
syncPromise = null;
|
|
}
|
|
},
|
|
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(),
|
|
userId: auth.user?.id,
|
|
ledgerIds: entry.ledgerIds,
|
|
entity: "entry",
|
|
entityId: entry.id,
|
|
action: "create",
|
|
payload: entry,
|
|
createdAt: new Date().toISOString(),
|
|
syncedAt: null,
|
|
};
|
|
|
|
await db.transaction("rw", db.entries, db.syncOperations, async () => {
|
|
await db.entries.put(entry);
|
|
await db.syncOperations.put(operation);
|
|
});
|
|
|
|
await this.refreshLocalEntries();
|
|
void this.resolveLocalConversion(entry.id);
|
|
void this.syncEntries();
|
|
},
|
|
async updateEntry(entry: LedgerEntry) {
|
|
const auth = useAuthStore();
|
|
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
|
|
|| existing.currency !== entry.currency
|
|
|| existing.exchangeRateDate !== exchangeRateDate;
|
|
const updated: LedgerEntry = {
|
|
...existing,
|
|
...entry,
|
|
ledgerIds: [...new Set(entry.ledgerIds)],
|
|
baseCurrency: "CNY",
|
|
baseAmount: conversionChanged ? (entry.currency === "CNY" ? entry.amount : null) : existing.baseAmount,
|
|
exchangeRate: conversionChanged ? (entry.currency === "CNY" ? "1" : null) : existing.exchangeRate,
|
|
exchangeRateDate,
|
|
exchangeRateEffectiveDate: conversionChanged
|
|
? (entry.currency === "CNY" ? exchangeRateDate : null)
|
|
: existing.exchangeRateEffectiveDate,
|
|
exchangeRateSource: conversionChanged ? "system" : existing.exchangeRateSource,
|
|
conversionStatus: conversionChanged
|
|
? (entry.currency === "CNY" ? "exact" : "pending")
|
|
: existing.conversionStatus,
|
|
updatedAt: new Date().toISOString(),
|
|
updatedBy: auth.user?.id ?? existing.updatedBy,
|
|
version: existing.version + 1,
|
|
};
|
|
const operation: SyncOperation = {
|
|
id: createId(),
|
|
userId: auth.user?.id,
|
|
ledgerIds: updated.ledgerIds,
|
|
entity: "entry",
|
|
entityId: updated.id,
|
|
action: "update",
|
|
payload: updated,
|
|
createdAt: updated.updatedAt,
|
|
syncedAt: null,
|
|
};
|
|
|
|
await db.transaction("rw", db.entries, db.syncOperations, async () => {
|
|
await db.entries.put(updated);
|
|
await db.syncOperations.put(operation);
|
|
});
|
|
|
|
await this.refreshLocalEntries();
|
|
if (conversionChanged) void this.resolveLocalConversion(updated.id);
|
|
void this.syncEntries();
|
|
return updated;
|
|
},
|
|
async resolveLocalConversion(entryId: string) {
|
|
const auth = useAuthStore();
|
|
if (!auth.user) return;
|
|
const db = await getUserDb(auth.user.id);
|
|
const entry = await db.entries.get(entryId);
|
|
if (!entry || entry.deletedAt || entry.currency === "CNY") return;
|
|
const cacheId = `${entry.currency}:${entry.exchangeRateDate}`;
|
|
let cached = await db.exchangeRates.get(cacheId);
|
|
if (!cached && (typeof navigator === "undefined" || navigator.onLine)) {
|
|
try {
|
|
const result = await apiRequest<{
|
|
rate: {
|
|
currency: CurrencyCode;
|
|
requestedDate: string;
|
|
effectiveDate: string | null;
|
|
cnyPerUnit: string | null;
|
|
status: "exact" | "fallback" | "pending";
|
|
};
|
|
}>(`/api/exchange-rates/${entry.currency}/${entry.exchangeRateDate}`);
|
|
if (result.rate.cnyPerUnit && result.rate.effectiveDate && result.rate.status !== "pending") {
|
|
cached = {
|
|
id: cacheId,
|
|
currency: result.rate.currency,
|
|
requestedDate: result.rate.requestedDate,
|
|
effectiveDate: result.rate.effectiveDate,
|
|
cnyPerUnit: result.rate.cnyPerUnit,
|
|
status: result.rate.status,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
await db.exchangeRates.put(cached);
|
|
}
|
|
} catch {
|
|
// The original-currency entry is already durable; conversion can retry after sync.
|
|
}
|
|
}
|
|
if (!cached) {
|
|
const candidates = await db.exchangeRates.where("currency").equals(entry.currency).toArray();
|
|
cached = candidates
|
|
.sort((left, right) => {
|
|
const leftFuture = left.requestedDate > entry.exchangeRateDate;
|
|
const rightFuture = right.requestedDate > entry.exchangeRateDate;
|
|
if (leftFuture !== rightFuture) return leftFuture ? 1 : -1;
|
|
return Math.abs(Date.parse(left.requestedDate) - Date.parse(entry.exchangeRateDate))
|
|
- Math.abs(Date.parse(right.requestedDate) - Date.parse(entry.exchangeRateDate));
|
|
})[0];
|
|
}
|
|
if (!cached) return;
|
|
|
|
const current = await db.entries.get(entryId);
|
|
if (
|
|
!current
|
|
|| current.deletedAt
|
|
|| current.amount !== entry.amount
|
|
|| current.currency !== entry.currency
|
|
|| current.exchangeRateDate !== entry.exchangeRateDate
|
|
) return;
|
|
const baseAmount = calculateCnyAmount(current.amount, current.currency, cached.cnyPerUnit);
|
|
await db.entries.update(entryId, {
|
|
baseAmount,
|
|
exchangeRate: cached.cnyPerUnit,
|
|
exchangeRateEffectiveDate: cached.effectiveDate,
|
|
exchangeRateSource: "system",
|
|
conversionStatus: cached.requestedDate === current.exchangeRateDate && cached.status === "exact"
|
|
? "exact"
|
|
: "fallback",
|
|
});
|
|
await this.refreshLocalEntries();
|
|
},
|
|
async deleteEntry(entryId: string) {
|
|
const auth = useAuthStore();
|
|
if (!auth.user) return;
|
|
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 = {
|
|
...existing,
|
|
deletedAt,
|
|
updatedAt: deletedAt,
|
|
updatedBy: auth.user?.id ?? existing.updatedBy,
|
|
version: existing.version + 1,
|
|
};
|
|
const operation: SyncOperation = {
|
|
id: createId(),
|
|
userId: auth.user?.id,
|
|
ledgerIds: deleted.ledgerIds,
|
|
entity: "entry",
|
|
entityId: deleted.id,
|
|
action: "delete",
|
|
payload: deleted,
|
|
createdAt: deletedAt,
|
|
syncedAt: null,
|
|
};
|
|
|
|
await db.transaction("rw", db.entries, db.syncOperations, async () => {
|
|
await db.entries.put(deleted);
|
|
await db.syncOperations.put(operation);
|
|
});
|
|
|
|
await this.refreshLocalEntries();
|
|
void this.syncEntries();
|
|
},
|
|
},
|
|
});
|