Add store-item Excel export/import for bulk price and stock updates.
Dashboard can download grouped product variants and re-upload to update price and stock. Also expose Content-Disposition for downloads and keep related product/customer API docs in sync. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4a5780e74d
commit
7277817b6e
@@ -1,7 +1,7 @@
|
||||
# Meshkee CMS API — Project Context
|
||||
|
||||
> Living reference for developers and AI assistants working on this codebase.
|
||||
> Last updated: August 14, 2026
|
||||
> Last updated: August 18, 2026
|
||||
|
||||
## What This Project Is
|
||||
|
||||
@@ -504,6 +504,8 @@ PATCH /businesses/:businessId/store-items/variants/:variantId
|
||||
}
|
||||
```
|
||||
|
||||
**Bulk Excel (dashboard):** `GET /businesses/:businessId/store-items/export` downloads one row per variant (`variant_id`, names, variation, `price`, `stock`). `POST /businesses/:businessId/store-items/import` (multipart `file`) updates only price and stock. Empty price = call for price; empty stock = unlimited. Does not add or remove variants.
|
||||
|
||||
### Technical forms (product categories)
|
||||
|
||||
- One form per product category
|
||||
|
||||
@@ -31,7 +31,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
||||
### Typical bootstrap sequence
|
||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||
2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials
|
||||
3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings)
|
||||
3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings)
|
||||
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
|
||||
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
||||
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl`
|
||||
|
||||
@@ -1197,6 +1197,7 @@
|
||||
"name": "Get published product by slug",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"description": "Product details plus relatedProducts (same category then same brand; in-stock first within each group).",
|
||||
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -449,7 +449,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ product }"
|
||||
"description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+806
-5
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"mysql2": "^3.23.2",
|
||||
"passport": "^0.7.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
@@ -15,6 +16,14 @@ function serializeBigInt(value: unknown): unknown {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (value instanceof StreamableFile) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(serializeBigInt);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ type CustomerListRow = {
|
||||
cellNumber: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
firstNameEn: string | null;
|
||||
lastNameEn: string | null;
|
||||
email: string | null;
|
||||
createdAt: Date;
|
||||
isEnabled: boolean;
|
||||
@@ -63,7 +65,10 @@ export class CustomersService {
|
||||
AND (
|
||||
u.first_name ILIKE ${nameLike}
|
||||
OR u.last_name ILIKE ${nameLike}
|
||||
OR u.first_name_en ILIKE ${nameLike}
|
||||
OR u.last_name_en ILIKE ${nameLike}
|
||||
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
|
||||
OR (COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')) ILIKE ${nameLike}
|
||||
)
|
||||
` : Prisma.empty}
|
||||
${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty}
|
||||
@@ -76,6 +81,8 @@ export class CustomersService {
|
||||
u.cell_number AS "cellNumber",
|
||||
u.first_name AS "firstName",
|
||||
u.last_name AS "lastName",
|
||||
u.first_name_en AS "firstNameEn",
|
||||
u.last_name_en AS "lastNameEn",
|
||||
u.email AS "email",
|
||||
COALESCE(bc.created_at, bu.created_at) AS "createdAt",
|
||||
COALESCE(bc.is_enabled, TRUE) AS "isEnabled",
|
||||
@@ -109,6 +116,8 @@ export class CustomersService {
|
||||
cellNumber: user.cellNumber,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
firstNameEn: user.firstNameEn,
|
||||
lastNameEn: user.lastNameEn,
|
||||
email: user.email,
|
||||
label: this.formatLabel(user),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
@@ -335,6 +344,8 @@ export class CustomersService {
|
||||
cellNumber: user.cellNumber,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
firstNameEn: user.firstNameEn,
|
||||
lastNameEn: user.lastNameEn,
|
||||
email: user.email,
|
||||
label: this.formatLabel(user),
|
||||
createdAt: membership.link.createdAt.toISOString(),
|
||||
@@ -375,6 +386,8 @@ export class CustomersService {
|
||||
const hasProfileUpdate =
|
||||
dto.firstName !== undefined ||
|
||||
dto.lastName !== undefined ||
|
||||
dto.firstNameEn !== undefined ||
|
||||
dto.lastNameEn !== undefined ||
|
||||
dto.email !== undefined ||
|
||||
dto.cellNumber !== undefined;
|
||||
|
||||
@@ -395,6 +408,10 @@ export class CustomersService {
|
||||
data: {
|
||||
firstName: dto.firstName?.trim(),
|
||||
lastName: dto.lastName?.trim(),
|
||||
firstNameEn:
|
||||
dto.firstNameEn !== undefined ? dto.firstNameEn.trim() || null : undefined,
|
||||
lastNameEn:
|
||||
dto.lastNameEn !== undefined ? dto.lastNameEn.trim() || null : undefined,
|
||||
email: dto.email !== undefined ? dto.email.trim() || null : undefined,
|
||||
cellNumber: dto.cellNumber?.trim(),
|
||||
},
|
||||
@@ -416,6 +433,8 @@ export class CustomersService {
|
||||
cellNumber: user.cellNumber,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
firstNameEn: user.firstNameEn,
|
||||
lastNameEn: user.lastNameEn,
|
||||
email: user.email,
|
||||
label: this.formatLabel(user),
|
||||
isEnabled: refreshed.isEnabled,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateCustomerDto {
|
||||
@IsOptional()
|
||||
@@ -8,13 +15,29 @@ export class UpdateCustomerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ValidateIf((_, value) => typeof value === 'string' && value.trim().length > 0)
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
firstNameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ValidateIf((_, value) => typeof value === 'string' && value.trim().length > 0)
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
lastNameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
+3
-1
@@ -24,7 +24,9 @@ async function bootstrap() {
|
||||
}),
|
||||
);
|
||||
app.useGlobalInterceptors(new BigIntSerializerInterceptor());
|
||||
app.enableCors();
|
||||
app.enableCors({
|
||||
exposedHeaders: ['Content-Disposition'],
|
||||
});
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
|
||||
@@ -30,6 +30,20 @@ type ProductWithRelations = Prisma.ProductGetPayload<{
|
||||
};
|
||||
}>;
|
||||
|
||||
const productListInclude = {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
} satisfies Prisma.ProductInclude;
|
||||
|
||||
const RELATED_PRODUCTS_LIMIT = 8;
|
||||
|
||||
const emptyStoreSummary = {
|
||||
variantCount: 0,
|
||||
minPrice: null as number | null,
|
||||
maxPrice: null as number | null,
|
||||
inStock: false,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(
|
||||
@@ -137,28 +151,7 @@ export class ProductsService {
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
|
||||
const storeSummaries = await this.loadStoreSummariesForProducts(
|
||||
businessId,
|
||||
items.map((item) => item.id),
|
||||
);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const product = await this.serializeProduct(item, undefined, {
|
||||
approvedCommentsOnly: true,
|
||||
});
|
||||
const store = storeSummaries.get(item.id.toString());
|
||||
return {
|
||||
...product,
|
||||
store: store ?? {
|
||||
variantCount: 0,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
inStock: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
const serialized = await this.serializePublicListItems(businessId, items);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
@@ -173,31 +166,21 @@ export class ProductsService {
|
||||
slug,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
include: productListInclude,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
const storeSummaries = await this.loadStoreSummariesForProducts(businessId, [product.id]);
|
||||
const serialized = await this.serializeProduct(product, undefined, {
|
||||
approvedCommentsOnly: true,
|
||||
});
|
||||
const [serializedList, relatedProducts] = await Promise.all([
|
||||
this.serializePublicListItems(businessId, [product]),
|
||||
this.loadRelatedPublicProducts(businessId, product),
|
||||
]);
|
||||
|
||||
return {
|
||||
product: {
|
||||
...serialized,
|
||||
store: storeSummaries.get(product.id.toString()) ?? {
|
||||
variantCount: 0,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
inStock: false,
|
||||
},
|
||||
},
|
||||
product: serializedList[0],
|
||||
relatedProducts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -697,6 +680,141 @@ export class ProductsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async serializePublicListItems(
|
||||
businessId: bigint,
|
||||
items: ProductWithRelations[],
|
||||
) {
|
||||
const storeSummaries = await this.loadStoreSummariesForProducts(
|
||||
businessId,
|
||||
items.map((item) => item.id),
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
const product = await this.serializeProduct(item, undefined, {
|
||||
approvedCommentsOnly: true,
|
||||
});
|
||||
return {
|
||||
...product,
|
||||
store: storeSummaries.get(item.id.toString()) ?? { ...emptyStoreSummary },
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Related products for the details page.
|
||||
* Relativity: same category first, then same brand.
|
||||
* Availability: in-stock (unlimited or qty > 0) before out-of-stock within each group.
|
||||
*/
|
||||
private async loadRelatedPublicProducts(
|
||||
businessId: bigint,
|
||||
source: ProductWithRelations,
|
||||
limit = RELATED_PRODUCTS_LIMIT,
|
||||
) {
|
||||
const collected: ProductWithRelations[] = [];
|
||||
const seen = new Set<string>([source.id.toString()]);
|
||||
|
||||
const remaining = () => limit - collected.length;
|
||||
|
||||
const append = (items: ProductWithRelations[]) => {
|
||||
for (const item of items) {
|
||||
if (collected.length >= limit) {
|
||||
break;
|
||||
}
|
||||
const id = item.id.toString();
|
||||
if (seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
collected.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPool = async (
|
||||
extraWhere: Prisma.ProductWhereInput,
|
||||
take: number,
|
||||
) => {
|
||||
if (take <= 0) {
|
||||
return [];
|
||||
}
|
||||
return this.prisma.product.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
status: ContentStatus.published,
|
||||
AND: [{ id: { not: source.id } }, extraWhere],
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
take,
|
||||
include: productListInclude,
|
||||
});
|
||||
};
|
||||
|
||||
const inStockWhere = this.publishedInStockWhere();
|
||||
const outOfStockWhere: Prisma.ProductWhereInput = { NOT: inStockWhere };
|
||||
|
||||
const assignment = await this.prisma.categoryAssignment.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: source.id,
|
||||
},
|
||||
select: { categoryId: true },
|
||||
});
|
||||
|
||||
if (assignment) {
|
||||
const categoryRows = await this.prisma.categoryAssignment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
categoryId: assignment.categoryId,
|
||||
entityId: { not: source.id },
|
||||
},
|
||||
select: { entityId: true },
|
||||
});
|
||||
const categoryIds = categoryRows.map((row) => row.entityId);
|
||||
if (categoryIds.length > 0) {
|
||||
const categoryWhere: Prisma.ProductWhereInput = { id: { in: categoryIds } };
|
||||
const [inStock, outOfStock] = await Promise.all([
|
||||
fetchPool({ AND: [categoryWhere, inStockWhere] }, limit),
|
||||
fetchPool({ AND: [categoryWhere, outOfStockWhere] }, limit),
|
||||
]);
|
||||
append(inStock);
|
||||
append(outOfStock);
|
||||
}
|
||||
}
|
||||
|
||||
if (source.brandId && remaining() > 0) {
|
||||
const brandWhere: Prisma.ProductWhereInput = { brandId: source.brandId };
|
||||
const take = remaining();
|
||||
const [inStock, outOfStock] = await Promise.all([
|
||||
fetchPool({ AND: [brandWhere, inStockWhere] }, take),
|
||||
fetchPool({ AND: [brandWhere, outOfStockWhere] }, take),
|
||||
]);
|
||||
append(inStock);
|
||||
append(outOfStock);
|
||||
}
|
||||
|
||||
return this.serializePublicListItems(businessId, collected);
|
||||
}
|
||||
|
||||
/** Active store variant with unlimited stock (null) or quantity > 0. */
|
||||
private publishedInStockWhere(): Prisma.ProductWhereInput {
|
||||
return {
|
||||
storeItem: {
|
||||
some: {
|
||||
isActive: true,
|
||||
variants: {
|
||||
some: {
|
||||
isActive: true,
|
||||
OR: [{ stockQuantity: null }, { stockQuantity: { gt: 0 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadStoreSummariesForProducts(
|
||||
businessId: bigint,
|
||||
productIds: bigint[],
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
export const STORE_ITEMS_SHEET_NAME = 'Store items';
|
||||
|
||||
export const STORE_ITEM_EXCEL_HEADERS = {
|
||||
variantId: 'variant_id',
|
||||
product: 'product',
|
||||
variation: 'variation',
|
||||
price: 'price',
|
||||
stock: 'stock',
|
||||
} as const;
|
||||
|
||||
type HeaderKey = keyof typeof STORE_ITEM_EXCEL_HEADERS;
|
||||
|
||||
const HEADER_ALIASES: Record<string, HeaderKey> = {
|
||||
variant_id: 'variantId',
|
||||
'variant id': 'variantId',
|
||||
id: 'variantId',
|
||||
'شناسه تنوع': 'variantId',
|
||||
product: 'product',
|
||||
product_en: 'product',
|
||||
product_fa: 'product',
|
||||
variation: 'variation',
|
||||
price: 'price',
|
||||
'قیمت': 'price',
|
||||
stock: 'stock',
|
||||
stock_quantity: 'stock',
|
||||
'موجودی': 'stock',
|
||||
};
|
||||
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
|
||||
export interface StoreItemExcelRow {
|
||||
variantId: string;
|
||||
productId: string;
|
||||
productEn: string;
|
||||
productFa: string;
|
||||
variation: string;
|
||||
price: number | null;
|
||||
stock: number | null;
|
||||
}
|
||||
|
||||
export interface ParsedStoreItemExcelRow {
|
||||
rowNumber: number;
|
||||
variantId: string;
|
||||
price: number | null;
|
||||
stock: number | null;
|
||||
}
|
||||
|
||||
export async function buildStoreItemsExcel(rows: StoreItemExcelRow[]): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'Meshkee';
|
||||
workbook.created = new Date();
|
||||
|
||||
const sheet = workbook.addWorksheet(STORE_ITEMS_SHEET_NAME);
|
||||
sheet.columns = [
|
||||
{ header: STORE_ITEM_EXCEL_HEADERS.variantId, key: 'variantId', width: 10 },
|
||||
{ header: STORE_ITEM_EXCEL_HEADERS.product, key: 'product', width: 52 },
|
||||
{ header: STORE_ITEM_EXCEL_HEADERS.variation, key: 'variation', width: 36 },
|
||||
{ header: STORE_ITEM_EXCEL_HEADERS.price, key: 'price', width: 14 },
|
||||
{ header: STORE_ITEM_EXCEL_HEADERS.stock, key: 'stock', width: 8 },
|
||||
{ width: 3 },
|
||||
{ header: 'دستورالعمل', width: 48 },
|
||||
{ header: 'Instructions', width: 48 },
|
||||
];
|
||||
|
||||
const header = sheet.getRow(1);
|
||||
header.font = { bold: true };
|
||||
header.alignment = { vertical: 'middle' };
|
||||
for (let col = 1; col <= 5; col += 1) {
|
||||
header.getCell(col).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE8EEF7' },
|
||||
};
|
||||
}
|
||||
|
||||
const groups = groupRowsByProduct(rows);
|
||||
groups.forEach((group, index) => {
|
||||
const first = group[0];
|
||||
if (!first) return;
|
||||
|
||||
const title = sheet.addRow({
|
||||
variantId: '',
|
||||
product: formatProductName(first.productFa, first.productEn),
|
||||
variation: '',
|
||||
price: null,
|
||||
stock: null,
|
||||
});
|
||||
title.height = 32;
|
||||
for (let col = 1; col <= 5; col += 1) {
|
||||
const cell = title.getCell(col);
|
||||
cell.font = { bold: true, size: 14 };
|
||||
cell.alignment = { vertical: 'middle', readingOrder: 'ltr' };
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFF3F3F3' },
|
||||
};
|
||||
}
|
||||
|
||||
for (const row of group) {
|
||||
const added = sheet.addRow({
|
||||
variantId: row.variantId,
|
||||
product: '',
|
||||
variation: row.variation,
|
||||
price: row.price,
|
||||
stock: row.stock,
|
||||
});
|
||||
added.getCell('variantId').numFmt = '@';
|
||||
added.getCell('price').numFmt = '#,##0';
|
||||
added.getCell('stock').numFmt = '0';
|
||||
}
|
||||
|
||||
if (index < groups.length - 1) {
|
||||
sheet.addRow([]);
|
||||
}
|
||||
});
|
||||
|
||||
writeInstructionColumns(sheet);
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1, rightToLeft: false }];
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
export async function parseStoreItemsExcel(buffer: Buffer): Promise<ParsedStoreItemExcelRow[]> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as unknown as Parameters<ExcelJS.Xlsx['load']>[0]);
|
||||
|
||||
const sheet =
|
||||
workbook.getWorksheet(STORE_ITEMS_SHEET_NAME) ?? workbook.worksheets[0];
|
||||
|
||||
if (!sheet) {
|
||||
throw new BadRequestException('Excel file has no worksheet.');
|
||||
}
|
||||
|
||||
const headerMap = readHeaderMap(sheet);
|
||||
const variantCol = headerMap.variantId;
|
||||
const priceCol = headerMap.price;
|
||||
const stockCol = headerMap.stock;
|
||||
|
||||
if (!variantCol || !priceCol || !stockCol) {
|
||||
throw new BadRequestException(
|
||||
'Excel is missing required columns: variant_id, price, stock.',
|
||||
);
|
||||
}
|
||||
|
||||
const rows: ParsedStoreItemExcelRow[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
|
||||
const variantId = cellText(row.getCell(variantCol).value);
|
||||
if (!variantId) return;
|
||||
|
||||
if (seenIds.has(variantId)) {
|
||||
throw new BadRequestException(
|
||||
`Row ${rowNumber}: duplicate variant_id "${variantId}".`,
|
||||
);
|
||||
}
|
||||
seenIds.add(variantId);
|
||||
|
||||
const price = parseOptionalNumber(row.getCell(priceCol).value, rowNumber, 'price');
|
||||
const stock = parseOptionalInteger(row.getCell(stockCol).value, rowNumber, 'stock');
|
||||
|
||||
rows.push({ rowNumber, variantId, price, stock });
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new BadRequestException('Excel has no store item rows to import.');
|
||||
}
|
||||
|
||||
if (rows.length > 50_000) {
|
||||
throw new BadRequestException('Excel has too many rows (max 50,000).');
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function writeInstructionColumns(sheet: ExcelJS.Worksheet) {
|
||||
const faLines = [
|
||||
'نام محصول، سپس تنوعها، سپس یک ردیف خالی.',
|
||||
'فقط ستونهای price و stock را در ردیف تنوع ویرایش کنید.',
|
||||
'ستون variant_id را تغییر ندهید.',
|
||||
'قیمت خالی = تماس برای قیمت.',
|
||||
'موجودی خالی = نامحدود. موجودی ۰ = ناموجود.',
|
||||
'این فایل محصولی اضافه یا حذف نمیکند.',
|
||||
];
|
||||
const enLines = [
|
||||
'Product name, then its variations, then a blank row.',
|
||||
'Edit only price and stock on variation rows.',
|
||||
'Do not change variant_id.',
|
||||
'Empty price = call for price.',
|
||||
'Empty stock = unlimited. Stock 0 = out of stock.',
|
||||
'This file does not add or remove products.',
|
||||
];
|
||||
|
||||
faLines.forEach((line, index) => {
|
||||
const row = sheet.getRow(index + 2);
|
||||
row.getCell(7).value = line;
|
||||
row.getCell(7).alignment = { wrapText: true, vertical: 'top', readingOrder: 'rtl' };
|
||||
row.getCell(8).value = enLines[index] ?? '';
|
||||
row.getCell(8).alignment = { wrapText: true, vertical: 'top', readingOrder: 'ltr' };
|
||||
});
|
||||
}
|
||||
|
||||
function groupRowsByProduct(rows: StoreItemExcelRow[]): StoreItemExcelRow[][] {
|
||||
const groups = new Map<string, StoreItemExcelRow[]>();
|
||||
const order: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.productId || `${row.productEn}\n${row.productFa}`;
|
||||
const group = groups.get(key);
|
||||
if (!group) {
|
||||
groups.set(key, [row]);
|
||||
order.push(key);
|
||||
} else {
|
||||
group.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return order.map((key) => groups.get(key) ?? []);
|
||||
}
|
||||
|
||||
export function storeItemsExcelFilename(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
|
||||
const pick = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? '';
|
||||
|
||||
const date = `${pick('year')}-${pick('month')}-${pick('day')}`;
|
||||
const time = `${pick('hour')}-${pick('minute')}`;
|
||||
return `store items - ${date} - ${time}.xlsx`;
|
||||
}
|
||||
|
||||
function formatProductName(productFa: string, productEn: string) {
|
||||
const fa = productFa.trim();
|
||||
const en = productEn.trim();
|
||||
if (fa && en) return `\u200E${fa} - ${en}`;
|
||||
return fa || en;
|
||||
}
|
||||
|
||||
function readHeaderMap(sheet: ExcelJS.Worksheet): Partial<Record<HeaderKey, number>> {
|
||||
const headerRow = sheet.getRow(1);
|
||||
const map: Partial<Record<HeaderKey, number>> = {};
|
||||
|
||||
headerRow.eachCell((cell, colNumber) => {
|
||||
const key = HEADER_ALIASES[normalizeHeader(cellText(cell.value))];
|
||||
if (key && !map[key]) {
|
||||
map[key] = colNumber;
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string) {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function cellText(value: ExcelJS.CellValue): string {
|
||||
if (value == null || value === '') return '';
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (value instanceof Date) return '';
|
||||
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
|
||||
return value.text.trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
|
||||
return value.richText.map((part) => part.text).join('').trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'result' in value) {
|
||||
return cellText(value.result as ExcelJS.CellValue);
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function parseOptionalNumber(
|
||||
value: ExcelJS.CellValue,
|
||||
rowNumber: number,
|
||||
field: string,
|
||||
): number | null {
|
||||
if (value == null || value === '') return null;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new BadRequestException(`Row ${rowNumber}: ${field} must be 0 or greater.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const text = normalizeNumericText(cellText(value));
|
||||
if (!text) return null;
|
||||
|
||||
const parsed = Number(text);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new BadRequestException(`Row ${rowNumber}: ${field} must be a number 0 or greater.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOptionalInteger(
|
||||
value: ExcelJS.CellValue,
|
||||
rowNumber: number,
|
||||
field: string,
|
||||
): number | null {
|
||||
const parsed = parseOptionalNumber(value, rowNumber, field);
|
||||
if (parsed == null) return null;
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new BadRequestException(`Row ${rowNumber}: ${field} must be a whole number.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeNumericText(value: string) {
|
||||
const withLatinDigits = [...value]
|
||||
.map((char) => {
|
||||
const index = PERSIAN_DIGITS.indexOf(char);
|
||||
return index >= 0 ? String(index) : char;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return withLatinDigits.replace(/,/g, '').replace(/\s+/g, '').trim();
|
||||
}
|
||||
@@ -1,4 +1,21 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
@@ -14,6 +31,7 @@ import {
|
||||
UpdateStoreItemVariantDto,
|
||||
} from './dto/store-items.dto';
|
||||
import { StoreItemsService } from './store-items.service';
|
||||
import { storeItemsExcelFilename } from './store-items-excel';
|
||||
|
||||
@Controller('businesses/:businessId/store-items')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
@@ -30,6 +48,40 @@ export class StoreItemsController {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@RequireBusinessPermission('products.read')
|
||||
async exportExcel(
|
||||
@Param('businessId') businessId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
const buffer = await this.service.exportExcel(businessId, user);
|
||||
const filename = storeItemsExcelFilename();
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||
length: buffer.length,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@RequireBusinessPermission('products.update')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
importExcel(
|
||||
@Param('businessId') businessId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Excel file is required.');
|
||||
}
|
||||
return this.service.importExcel(businessId, file, user);
|
||||
}
|
||||
|
||||
@Get('by-product/:productId')
|
||||
@RequireBusinessPermission('products.read')
|
||||
getByProduct(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SyncProductStoreItemVariantsDto,
|
||||
UpdateStoreItemVariantDto,
|
||||
} from './dto/store-items.dto';
|
||||
import { buildStoreItemsExcel, parseStoreItemsExcel } from './store-items-excel';
|
||||
|
||||
const variantInclude = {
|
||||
storeItem: {
|
||||
@@ -42,6 +43,12 @@ type VariantWithRelations = Prisma.StoreItemVariantGetPayload<{
|
||||
include: typeof variantInclude;
|
||||
}>;
|
||||
|
||||
function sameNullableNumber(a: number | null, b: number | null) {
|
||||
if (a === null && b === null) return true;
|
||||
if (a === null || b === null) return false;
|
||||
return Math.abs(a - b) < 0.005;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StoreItemsService {
|
||||
constructor(
|
||||
@@ -268,6 +275,136 @@ export class StoreItemsService {
|
||||
};
|
||||
}
|
||||
|
||||
async exportExcel(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||
|
||||
const variants = await this.prisma.storeItemVariant.findMany({
|
||||
where: { businessId },
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: variantInclude,
|
||||
});
|
||||
|
||||
const rows = variants.map((variant) => {
|
||||
const product = variant.storeItem.product;
|
||||
const content = this.asRecord(product.content);
|
||||
const variation = variant.selections
|
||||
.map((selection) => `${selection.variation.name}: ${selection.option.label}`)
|
||||
.join(' · ');
|
||||
|
||||
return {
|
||||
variantId: variant.id.toString(),
|
||||
productId: product.id.toString(),
|
||||
productEn: product.title,
|
||||
productFa: (content.nameFa as string | null | undefined) ?? '',
|
||||
variation,
|
||||
price: variant.price === null ? null : Number(variant.price),
|
||||
stock: variant.stockQuantity,
|
||||
};
|
||||
});
|
||||
|
||||
return buildStoreItemsExcel(rows);
|
||||
}
|
||||
|
||||
async importExcel(businessIdRaw: string, file: Express.Multer.File | undefined, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('Excel file is required.');
|
||||
}
|
||||
|
||||
const name = file.originalname?.toLowerCase() ?? '';
|
||||
if (name && !name.endsWith('.xlsx')) {
|
||||
throw new BadRequestException('Please upload an .xlsx Excel file.');
|
||||
}
|
||||
|
||||
const rows = await parseStoreItemsExcel(file.buffer);
|
||||
const ids = rows.map((row) => {
|
||||
try {
|
||||
return BigInt(row.variantId);
|
||||
} catch {
|
||||
throw new BadRequestException(
|
||||
`Row ${row.rowNumber}: variant_id "${row.variantId}" is not valid.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await this.prisma.storeItemVariant.findMany({
|
||||
where: { businessId, id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
price: true,
|
||||
stockQuantity: true,
|
||||
compareAtPrice: true,
|
||||
},
|
||||
});
|
||||
const existingById = new Map(existing.map((item) => [item.id.toString(), item]));
|
||||
|
||||
const missing = rows.filter((row) => !existingById.has(row.variantId));
|
||||
if (missing.length > 0) {
|
||||
const preview = missing
|
||||
.slice(0, 8)
|
||||
.map((row) => `Row ${row.rowNumber}: variant_id "${row.variantId}" was not found.`)
|
||||
.join(' ');
|
||||
throw new BadRequestException(
|
||||
missing.length === 1
|
||||
? preview
|
||||
: `${missing.length} unknown variant_id values. No changes applied. ${preview}`,
|
||||
);
|
||||
}
|
||||
|
||||
const changes = rows.flatMap((row) => {
|
||||
const current = existingById.get(row.variantId);
|
||||
if (!current) return [];
|
||||
|
||||
const currentPrice = current.price === null ? null : Number(current.price);
|
||||
const priceChanged = !sameNullableNumber(currentPrice, row.price);
|
||||
const stockChanged = current.stockQuantity !== row.stock;
|
||||
if (!priceChanged && !stockChanged) return [];
|
||||
|
||||
const compareAt =
|
||||
current.compareAtPrice === null ? null : Number(current.compareAtPrice);
|
||||
const clearCompareAt =
|
||||
compareAt !== null &&
|
||||
(row.price === null || compareAt >= row.price);
|
||||
|
||||
return [
|
||||
{
|
||||
id: current.id,
|
||||
price: row.price,
|
||||
stockQuantity: row.stock,
|
||||
clearCompareAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (changes.length > 0) {
|
||||
await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
for (const item of changes) {
|
||||
await tx.storeItemVariant.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
price: item.price,
|
||||
stockQuantity: item.stockQuantity,
|
||||
...(item.clearCompareAt ? { compareAtPrice: null } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 120_000, maxWait: 15_000 },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Store item prices and stock updated from Excel',
|
||||
total: rows.length,
|
||||
updated: changes.length,
|
||||
unchanged: rows.length - changes.length,
|
||||
};
|
||||
}
|
||||
|
||||
async batchCreateVariants(
|
||||
businessIdRaw: string,
|
||||
dto: BatchCreateStoreItemsDto,
|
||||
|
||||
@@ -31,7 +31,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
||||
### Typical bootstrap sequence
|
||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||
2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials
|
||||
3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings)
|
||||
3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings)
|
||||
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
|
||||
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
||||
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl`
|
||||
|
||||
@@ -1197,6 +1197,7 @@
|
||||
"name": "Get published product by slug",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"description": "Product details plus relatedProducts (same category then same brand; in-stock first within each group).",
|
||||
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -449,7 +449,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ product }"
|
||||
"description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user