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:
2026-11-06 11:48:04 +03:00
parent 8e7e374778
commit f0b570284e
23 changed files with 1893 additions and 1 deletions
+4
View File
@@ -35,6 +35,8 @@ describe("seed", () => {
expect(summary.created.assets).toBe(4);
expect(summary.created.budgets).toBe(2);
expect(summary.created.stock).toBe(19);
expect(summary.created.stores).toBe(8);
expect(summary.created.products).toBe(22);
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
});
@@ -55,6 +57,8 @@ describe("seed", () => {
assets: 0,
budgets: 0,
stock: 0,
stores: 0,
products: 0,
});
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
+12
View File
@@ -13,6 +13,7 @@ import { runMigrations } from "../migrations";
import { seedContacts } from "./contacts";
import { seedAssets } from "./assets";
import { seedBudgets } from "./budgets";
import { seedShopping } from "./shopping";
import { seedStock } from "./stock";
import { seedInKind } from "./in-kind";
import { seedInstallments } from "./installments";
@@ -57,6 +58,8 @@ export type SeedSummary = {
assets: number;
budgets: number;
stock: number;
stores: number;
products: number;
};
accounts: SeedAccountRow[];
};
@@ -84,6 +87,8 @@ const DOMAIN_COLLECTIONS = [
"productTypes",
"stockMovements",
"stockCounts",
"stores",
"products",
];
type AuthApi = ReturnType<typeof getAuth>["api"];
@@ -140,6 +145,8 @@ export async function runSeed({
assets: 0,
budgets: 0,
stock: 0,
stores: 0,
products: 0,
},
accounts: [],
};
@@ -324,6 +331,11 @@ export async function runSeed({
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
summary.created.stock = await seedStock(householdIds, userIdMap);
if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`);
const shopping = await seedShopping(householdIds, userIdMap);
summary.created.stores = shopping.stores;
summary.created.products = shopping.products;
if (shopping.stores || shopping.products)
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
// 7) Geliştirme hesapları tablosu
const all = [
+328
View File
@@ -0,0 +1,328 @@
import { Types } from "mongoose";
import { ProductModel } from "@/models/product";
import { ProductTypeModel } from "@/models/product-type";
import { StoreModel } from "@/models/store";
import type { MealCardProvider } from "@/shared/accounts";
import { autoConvertible } from "@/shared/products";
import { foldTr } from "@/shared/search";
import type { StoreKind } from "@/shared/stores";
/**
* Örnek mağazalar ve ürünler (Faz 12). Çakır Ailesi (Kadıköy) için marketler, pazar ve online
* platform; stok ürün tiplerine bağlı marka/paketler. Elle dönüşüm gereken örnekler: deterjan
* (kg → yıkama), tablet paketi (paket → tablet), diş macunu/şampuan (ml → adet). Görsel yok.
* Idempotent (`seedRef`). Miktarlar binde bir, tutarlar kuruş.
*/
type SeedStore = {
ref: string;
name: string;
kind: StoreKind;
branch?: string;
distanceM?: number;
carry: number;
deliveryFee?: number;
freeDeliveryMin?: number;
mealCards?: MealCardProvider[];
note?: string;
};
export const STORES: SeedStore[] = [
{
ref: "migros",
name: "Migros",
kind: "market",
branch: "Bahariye",
distanceM: 1_200,
carry: 2,
mealCards: ["pluxee", "multinet", "edenred", "setcard"],
},
{ ref: "a101", name: "A101", kind: "market", branch: "Moda", distanceM: 400, carry: 1 },
{ ref: "bim", name: "BİM", kind: "market", branch: "Yeldeğirmeni", distanceM: 650, carry: 2 },
{
ref: "sok",
name: "Şok",
kind: "market",
branch: "Rasimpaşa",
distanceM: 300,
carry: 1,
mealCards: ["multinet", "pluxee"],
},
{
ref: "carrefoursa",
name: "CarrefourSA",
kind: "market",
branch: "Acıbadem",
distanceM: 3_800,
carry: 4,
mealCards: ["pluxee", "multinet", "edenred", "setcard", "metropol"],
note: "Hafta sonu arabayla; büyük alışveriş.",
},
{
ref: "sali-pazari",
name: "Salı Pazarı",
kind: "bazaar",
branch: "Kadıköy",
distanceM: 1_900,
carry: 4,
note: "Salı ve cuma; sebze-meyve.",
},
{
ref: "getir-buyuk",
name: "Getir Büyük",
kind: "online",
carry: 1,
deliveryFee: 39_90,
freeDeliveryMin: 750_00,
mealCards: ["multinet"],
},
{
ref: "metro",
name: "Metro Toptancı",
kind: "market",
branch: "Kozyatağı",
distanceM: 9_500,
carry: 5,
},
];
type SeedProduct = {
ref: string;
/** Stok seed'indeki ürün tipi `seedRef`'i. */
type: string;
brand: string;
name?: string;
barcode?: string;
qty: number;
unit: string;
/** Elle dönüşüm (bir paket = bu kadar ürün tipi birimi). */
typeQty?: number;
};
const K = 1000;
export const PRODUCTS: SeedProduct[] = [
{
ref: "ariel-5kg",
type: "toz-deterjan",
brand: "Ariel",
name: "Matik Dağ Esintisi",
qty: 5 * K,
unit: "kg",
typeQty: 33 * K,
barcode: "8690000100015",
},
{
ref: "omo-6kg",
type: "toz-deterjan",
brand: "Omo",
name: "Matik Active Fresh",
qty: 6 * K,
unit: "kg",
typeQty: 40 * K,
},
{
ref: "bingo-4kg",
type: "toz-deterjan",
brand: "Bingo",
name: "Matik",
qty: 4 * K,
unit: "kg",
typeQty: 26 * K,
},
{
ref: "fairy-50",
type: "tablet-deterjan",
brand: "Fairy",
name: "Platinum Plus",
qty: 1 * K,
unit: "paket",
typeQty: 50 * K,
barcode: "8690000100022",
},
{
ref: "finish-40",
type: "tablet-deterjan",
brand: "Finish",
name: "Quantum",
qty: 40 * K,
unit: "tablet",
},
{
ref: "yayla-mercimek",
type: "kirmizi-mercimek",
brand: "Yayla",
name: "Kırmızı Mercimek",
qty: 1 * K,
unit: "kg",
},
{
ref: "reis-mercimek",
type: "kirmizi-mercimek",
brand: "Reis",
name: "Kırmızı Mercimek",
qty: 1 * K,
unit: "kg",
},
{
ref: "duru-mercimek",
type: "kirmizi-mercimek",
brand: "Duru",
name: "Kırmızı Mercimek",
qty: 2_500,
unit: "kg",
},
{ ref: "yayla-baldo", type: "pirinc", brand: "Yayla", name: "Baldo", qty: 2_500, unit: "kg" },
{ ref: "reis-baldo", type: "pirinc", brand: "Reis", name: "Baldo", qty: 1 * K, unit: "kg" },
{
ref: "pinar-sut",
type: "sut",
brand: "Pınar",
name: "Tam Yağlı Süt",
qty: 1 * K,
unit: "lt",
barcode: "8690000100039",
},
{ ref: "sutas-sut", type: "sut", brand: "Sütaş", name: "Tam Yağlı Süt", qty: 1 * K, unit: "lt" },
{
ref: "icim-sut-500",
type: "sut",
brand: "İçim",
name: "Yarım Yağlı Süt",
qty: 500 * K,
unit: "ml",
},
{
ref: "keskinoglu-30",
type: "yumurta",
brand: "Keskinoğlu",
name: "L Boy 30'lu",
qty: 30 * K,
unit: "adet",
},
{
ref: "koy-yumurta-15",
type: "yumurta",
brand: "Köy Yumurtası",
name: "15'li",
qty: 15 * K,
unit: "adet",
},
{
ref: "pinar-peynir",
type: "beyaz-peynir",
brand: "Pınar",
name: "Beyaz Peynir",
qty: 500 * K,
unit: "g",
},
{
ref: "yumos-1440",
type: "yumusatici",
brand: "Yumoş",
name: "Extra Lilyum",
qty: 1_440 * K,
unit: "ml",
},
{
ref: "selpak-32",
type: "tuvalet-kagidi",
brand: "Selpak",
name: "32'li",
qty: 32 * K,
unit: "rulo",
},
{
ref: "solo-24",
type: "tuvalet-kagidi",
brand: "Solo",
name: "24'lü",
qty: 24 * K,
unit: "rulo",
},
{
ref: "colgate-75",
type: "dis-macunu",
brand: "Colgate",
name: "Total 75 ml",
qty: 75 * K,
unit: "ml",
typeQty: 1 * K,
},
{
ref: "sensodyne-75",
type: "dis-macunu",
brand: "Sensodyne",
name: "Repair 75 ml",
qty: 75 * K,
unit: "ml",
typeQty: 1 * K,
},
{
ref: "elidor-500",
type: "sampuan",
brand: "Elidor",
name: "500 ml",
qty: 500 * K,
unit: "ml",
typeQty: 1 * K,
},
];
export async function seedShopping(
householdIds: Map<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<{ stores: number; products: number }> {
const actor = new Types.ObjectId(users.get("ayse")!);
const householdId = householdIds.get("Çakır Ailesi")!;
let stores = 0;
for (const s of STORES) {
if (await StoreModel.exists({ householdId, seedRef: s.ref })) continue;
await StoreModel.create({
householdId,
name: s.name,
nameKey: foldTr(s.name),
kind: s.kind,
branch: s.branch ?? "",
distanceM: s.kind === "online" ? null : (s.distanceM ?? null),
carryDifficulty: s.carry,
deliveryFeeMinor: s.kind === "online" ? (s.deliveryFee ?? null) : null,
freeDeliveryMinMinor: s.kind === "online" ? (s.freeDeliveryMin ?? null) : null,
mealCardAccepted: Boolean(s.mealCards?.length),
mealCardProviders: s.mealCards ?? [],
note: s.note ?? "",
seedRef: s.ref,
createdBy: actor,
updatedBy: actor,
});
stores++;
}
const types = await ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1, unit: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string; unit: string }[]>();
const typeByRef = new Map(types.map((x) => [x.seedRef, x]));
let products = 0;
for (const p of PRODUCTS) {
const type = typeByRef.get(p.type);
if (!type || (await ProductModel.exists({ householdId, seedRef: p.ref }))) continue;
if (!autoConvertible(p.unit, type.unit) && !p.typeQty)
throw new Error(`Seed ürünü ${p.ref}: ${p.unit} → ${type.unit} dönüşümü eksik`);
await ProductModel.create({
householdId,
productTypeId: type._id,
brand: p.brand,
name: p.name ?? "",
searchKey: foldTr(`${p.brand} ${p.name ?? ""}`),
barcode: p.barcode ?? null,
packageQuantity: p.qty,
packageUnit: p.unit,
typeQuantity: autoConvertible(p.unit, type.unit) ? null : p.typeQty,
seedRef: p.ref,
createdBy: actor,
updatedBy: actor,
});
products++;
}
return { stores, products };
}