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
|
# Meshkee CMS API — Project Context
|
||||||
|
|
||||||
> Living reference for developers and AI assistants working on this codebase.
|
> 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
|
## 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)
|
### Technical forms (product categories)
|
||||||
|
|
||||||
- One form per product category
|
- 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
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||||
2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials
|
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).
|
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`
|
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`
|
- 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",
|
"name": "Get published product by slug",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
|
"description": "Product details plus relatedProducts (same category then same brand; in-stock first within each group).",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}"
|
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -449,7 +449,7 @@
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"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",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"mysql2": "^3.23.2",
|
"mysql2": "^3.23.2",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor,
|
NestInterceptor,
|
||||||
|
StreamableFile,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Observable, map } from 'rxjs';
|
import { Observable, map } from 'rxjs';
|
||||||
|
|
||||||
@@ -15,6 +16,14 @@ function serializeBigInt(value: unknown): unknown {
|
|||||||
return value.toISOString();
|
return value.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value instanceof StreamableFile) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map(serializeBigInt);
|
return value.map(serializeBigInt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type CustomerListRow = {
|
|||||||
cellNumber: string;
|
cellNumber: string;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
|
firstNameEn: string | null;
|
||||||
|
lastNameEn: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@@ -63,7 +65,10 @@ export class CustomersService {
|
|||||||
AND (
|
AND (
|
||||||
u.first_name ILIKE ${nameLike}
|
u.first_name ILIKE ${nameLike}
|
||||||
OR u.last_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, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
|
||||||
|
OR (COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')) ILIKE ${nameLike}
|
||||||
)
|
)
|
||||||
` : Prisma.empty}
|
` : Prisma.empty}
|
||||||
${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : 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.cell_number AS "cellNumber",
|
||||||
u.first_name AS "firstName",
|
u.first_name AS "firstName",
|
||||||
u.last_name AS "lastName",
|
u.last_name AS "lastName",
|
||||||
|
u.first_name_en AS "firstNameEn",
|
||||||
|
u.last_name_en AS "lastNameEn",
|
||||||
u.email AS "email",
|
u.email AS "email",
|
||||||
COALESCE(bc.created_at, bu.created_at) AS "createdAt",
|
COALESCE(bc.created_at, bu.created_at) AS "createdAt",
|
||||||
COALESCE(bc.is_enabled, TRUE) AS "isEnabled",
|
COALESCE(bc.is_enabled, TRUE) AS "isEnabled",
|
||||||
@@ -109,6 +116,8 @@ export class CustomersService {
|
|||||||
cellNumber: user.cellNumber,
|
cellNumber: user.cellNumber,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
firstNameEn: user.firstNameEn,
|
||||||
|
lastNameEn: user.lastNameEn,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
label: this.formatLabel(user),
|
label: this.formatLabel(user),
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
@@ -335,6 +344,8 @@ export class CustomersService {
|
|||||||
cellNumber: user.cellNumber,
|
cellNumber: user.cellNumber,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
firstNameEn: user.firstNameEn,
|
||||||
|
lastNameEn: user.lastNameEn,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
label: this.formatLabel(user),
|
label: this.formatLabel(user),
|
||||||
createdAt: membership.link.createdAt.toISOString(),
|
createdAt: membership.link.createdAt.toISOString(),
|
||||||
@@ -375,6 +386,8 @@ export class CustomersService {
|
|||||||
const hasProfileUpdate =
|
const hasProfileUpdate =
|
||||||
dto.firstName !== undefined ||
|
dto.firstName !== undefined ||
|
||||||
dto.lastName !== undefined ||
|
dto.lastName !== undefined ||
|
||||||
|
dto.firstNameEn !== undefined ||
|
||||||
|
dto.lastNameEn !== undefined ||
|
||||||
dto.email !== undefined ||
|
dto.email !== undefined ||
|
||||||
dto.cellNumber !== undefined;
|
dto.cellNumber !== undefined;
|
||||||
|
|
||||||
@@ -395,6 +408,10 @@ export class CustomersService {
|
|||||||
data: {
|
data: {
|
||||||
firstName: dto.firstName?.trim(),
|
firstName: dto.firstName?.trim(),
|
||||||
lastName: dto.lastName?.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,
|
email: dto.email !== undefined ? dto.email.trim() || null : undefined,
|
||||||
cellNumber: dto.cellNumber?.trim(),
|
cellNumber: dto.cellNumber?.trim(),
|
||||||
},
|
},
|
||||||
@@ -416,6 +433,8 @@ export class CustomersService {
|
|||||||
cellNumber: user.cellNumber,
|
cellNumber: user.cellNumber,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
firstNameEn: user.firstNameEn,
|
||||||
|
lastNameEn: user.lastNameEn,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
label: this.formatLabel(user),
|
label: this.formatLabel(user),
|
||||||
isEnabled: refreshed.isEnabled,
|
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 {
|
export class UpdateCustomerDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -8,13 +15,29 @@ export class UpdateCustomerDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
lastName?: string;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|||||||
+3
-1
@@ -24,7 +24,9 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.useGlobalInterceptors(new BigIntSerializerInterceptor());
|
app.useGlobalInterceptors(new BigIntSerializerInterceptor());
|
||||||
app.enableCors();
|
app.enableCors({
|
||||||
|
exposedHeaders: ['Content-Disposition'],
|
||||||
|
});
|
||||||
|
|
||||||
const port = process.env.PORT ?? 3000;
|
const port = process.env.PORT ?? 3000;
|
||||||
await app.listen(port);
|
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()
|
@Injectable()
|
||||||
export class ProductsService {
|
export class ProductsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -137,28 +151,7 @@ export class ProductsService {
|
|||||||
this.prisma.product.count({ where }),
|
this.prisma.product.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const storeSummaries = await this.loadStoreSummariesForProducts(
|
const serialized = await this.serializePublicListItems(businessId, items);
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { items: serialized, total, page, pageSize };
|
return { items: serialized, total, page, pageSize };
|
||||||
}
|
}
|
||||||
@@ -173,31 +166,21 @@ export class ProductsService {
|
|||||||
slug,
|
slug,
|
||||||
status: ContentStatus.published,
|
status: ContentStatus.published,
|
||||||
},
|
},
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
throw new NotFoundException('Product not found');
|
throw new NotFoundException('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeSummaries = await this.loadStoreSummariesForProducts(businessId, [product.id]);
|
const [serializedList, relatedProducts] = await Promise.all([
|
||||||
const serialized = await this.serializeProduct(product, undefined, {
|
this.serializePublicListItems(businessId, [product]),
|
||||||
approvedCommentsOnly: true,
|
this.loadRelatedPublicProducts(businessId, product),
|
||||||
});
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
product: {
|
product: serializedList[0],
|
||||||
...serialized,
|
relatedProducts,
|
||||||
store: storeSummaries.get(product.id.toString()) ?? {
|
|
||||||
variantCount: 0,
|
|
||||||
minPrice: null,
|
|
||||||
maxPrice: null,
|
|
||||||
inStock: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(
|
private async loadStoreSummariesForProducts(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
productIds: 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 { AuthUser } from '../auth/auth.types';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
@@ -14,6 +31,7 @@ import {
|
|||||||
UpdateStoreItemVariantDto,
|
UpdateStoreItemVariantDto,
|
||||||
} from './dto/store-items.dto';
|
} from './dto/store-items.dto';
|
||||||
import { StoreItemsService } from './store-items.service';
|
import { StoreItemsService } from './store-items.service';
|
||||||
|
import { storeItemsExcelFilename } from './store-items-excel';
|
||||||
|
|
||||||
@Controller('businesses/:businessId/store-items')
|
@Controller('businesses/:businessId/store-items')
|
||||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
@@ -30,6 +48,40 @@ export class StoreItemsController {
|
|||||||
return this.service.list(businessId, query, user);
|
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')
|
@Get('by-product/:productId')
|
||||||
@RequireBusinessPermission('products.read')
|
@RequireBusinessPermission('products.read')
|
||||||
getByProduct(
|
getByProduct(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
SyncProductStoreItemVariantsDto,
|
SyncProductStoreItemVariantsDto,
|
||||||
UpdateStoreItemVariantDto,
|
UpdateStoreItemVariantDto,
|
||||||
} from './dto/store-items.dto';
|
} from './dto/store-items.dto';
|
||||||
|
import { buildStoreItemsExcel, parseStoreItemsExcel } from './store-items-excel';
|
||||||
|
|
||||||
const variantInclude = {
|
const variantInclude = {
|
||||||
storeItem: {
|
storeItem: {
|
||||||
@@ -42,6 +43,12 @@ type VariantWithRelations = Prisma.StoreItemVariantGetPayload<{
|
|||||||
include: typeof variantInclude;
|
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()
|
@Injectable()
|
||||||
export class StoreItemsService {
|
export class StoreItemsService {
|
||||||
constructor(
|
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(
|
async batchCreateVariants(
|
||||||
businessIdRaw: string,
|
businessIdRaw: string,
|
||||||
dto: BatchCreateStoreItemsDto,
|
dto: BatchCreateStoreItemsDto,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
|||||||
### Typical bootstrap sequence
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||||
2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials
|
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).
|
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`
|
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`
|
- 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",
|
"name": "Get published product by slug",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
|
"description": "Product details plus relatedProducts (same category then same brand; in-stock first within each group).",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}"
|
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -449,7 +449,7 @@
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"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