feat(shopping): mağazalar, zahmet skoru ve ürünler (marka/paket, dönüşüm, görseller)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership } from "@/features/households/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import type { MealCardProvider } from "@/shared/accounts";
|
||||
import type { StoreForm } from "@/shared/schemas/store";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import {
|
||||
effortLevel,
|
||||
effortScore,
|
||||
sortStores,
|
||||
type EffortLevel,
|
||||
type StoreKind,
|
||||
} from "@/shared/stores";
|
||||
|
||||
/**
|
||||
* Mağazalar (Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma: member.
|
||||
* Zahmet skoru okuma sırasında hesaplanır (saf motor: shared/stores).
|
||||
*/
|
||||
|
||||
const t = tr.stores;
|
||||
|
||||
type StoreLean = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
kind: StoreKind;
|
||||
branch: string;
|
||||
distanceM: number | null;
|
||||
carryDifficulty: number;
|
||||
deliveryFeeMinor: number | null;
|
||||
freeDeliveryMinMinor: number | null;
|
||||
mealCardAccepted: boolean;
|
||||
mealCardProviders: MealCardProvider[];
|
||||
note: string;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
export type StoreView = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: StoreKind;
|
||||
branch: string;
|
||||
/** "Migros · Kadıköy". */
|
||||
label: string;
|
||||
distanceM: number | null;
|
||||
carryDifficulty: number;
|
||||
deliveryFeeMinor: number | null;
|
||||
freeDeliveryMinMinor: number | null;
|
||||
mealCardAccepted: boolean;
|
||||
mealCardProviders: MealCardProvider[];
|
||||
note: string;
|
||||
archived: boolean;
|
||||
effort: number;
|
||||
effortLevel: EffortLevel;
|
||||
};
|
||||
|
||||
export const storeLabel = (s: { name: string; branch: string }) =>
|
||||
s.branch ? `${s.name} · ${s.branch}` : s.name;
|
||||
|
||||
function toView(s: StoreLean): StoreView {
|
||||
const effort = effortScore(s);
|
||||
return {
|
||||
id: String(s._id),
|
||||
name: s.name,
|
||||
kind: s.kind,
|
||||
branch: s.branch,
|
||||
label: storeLabel(s),
|
||||
distanceM: s.distanceM,
|
||||
carryDifficulty: s.carryDifficulty,
|
||||
deliveryFeeMinor: s.deliveryFeeMinor,
|
||||
freeDeliveryMinMinor: s.freeDeliveryMinMinor,
|
||||
mealCardAccepted: s.mealCardAccepted,
|
||||
mealCardProviders: s.mealCardProviders,
|
||||
note: s.note,
|
||||
archived: Boolean(s.archivedAt),
|
||||
effort,
|
||||
effortLevel: effortLevel(effort),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listStores(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<{ stores: StoreView[]; canWrite: boolean } | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const rows = await StoreModel.find({ householdId: m.householdId }).lean<StoreLean[]>();
|
||||
return {
|
||||
stores: sortStores(rows.map(toView), "name"),
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Aynı ad + şube (Türkçe katlanmış) aktif mağazalarda tektir. */
|
||||
async function isDuplicate(
|
||||
householdId: Types.ObjectId,
|
||||
input: Pick<StoreForm, "name" | "branch">,
|
||||
except?: Types.ObjectId,
|
||||
) {
|
||||
const rows = await StoreModel.find({
|
||||
householdId,
|
||||
nameKey: foldTr(input.name),
|
||||
archivedAt: null,
|
||||
...(except ? { _id: { $ne: except } } : {}),
|
||||
})
|
||||
.select({ branch: 1 })
|
||||
.lean<{ branch: string }[]>();
|
||||
return rows.some((r) => foldTr(r.branch) === foldTr(input.branch));
|
||||
}
|
||||
|
||||
const fields = (input: StoreForm) => ({ ...input, nameKey: foldTr(input.name) });
|
||||
|
||||
export async function createStore(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: StoreForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
if (await isDuplicate(m.householdId, input))
|
||||
return fail(t.validation.duplicate, { name: t.validation.duplicate });
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await StoreModel.create({
|
||||
householdId: m.householdId,
|
||||
...fields(input),
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateStore(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
storeId: string,
|
||||
input: StoreForm,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(storeId);
|
||||
const current = id
|
||||
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (current.archivedAt) return fail(t.validation.archived);
|
||||
if (await isDuplicate(m.householdId, input, current._id))
|
||||
return fail(t.validation.duplicate, { name: t.validation.duplicate });
|
||||
await StoreModel.updateOne(
|
||||
{ _id: current._id, householdId: m.householdId },
|
||||
{ $set: { ...fields(input), updatedBy: new Types.ObjectId(actorId) } },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function setStoreArchived(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
storeId: string,
|
||||
archived: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(storeId);
|
||||
const current = id
|
||||
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean<StoreLean>()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (!archived && (await isDuplicate(m.householdId, current, current._id)))
|
||||
return fail(t.validation.duplicate);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await StoreModel.updateOne(
|
||||
{ _id: current._id, householdId: m.householdId },
|
||||
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: archived ? "store.archive" : "store.unarchive",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "store",
|
||||
targetId: storeId,
|
||||
diff: { name: storeLabel(current) },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user