Flatlogic Bot 64d433d6e7 amr7aj
2026-03-31 19:41:37 +00:00

561 lines
16 KiB
JavaScript

const db = require('../db/models');
const ValidationError = require('./notifications/errors/validation');
const { Op } = db.Sequelize;
const PAYMENT_METHODS = new Set(['cash', 'card', 'transfer', 'mixed']);
const PRICING_ACTIONS = new Set(['set_rate', 'apply_prices', 'restore_prices']);
const toNumber = (value, fallback = 0) => {
const parsed = Number.parseFloat(String(value ?? ''));
return Number.isFinite(parsed) ? parsed : fallback;
};
const roundMoney = (value) => Number(toNumber(value).toFixed(2));
const formatInvoiceNumber = () => {
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14);
const suffix = Math.floor(Math.random() * 900 + 100);
return `INV-${stamp}-${suffix}`;
};
const buildOrgWhere = (currentUser) => {
if (currentUser?.app_role?.globalAccess) {
return {};
}
if (!currentUser?.organizationsId) {
return { id: null };
}
return {
organizationsId: currentUser.organizationsId,
};
};
const buildShopWhere = (currentUser, shopId) => {
const orgWhere = buildOrgWhere(currentUser);
if (orgWhere.id === null) {
return orgWhere;
}
return {
...orgWhere,
...(shopId ? { id: shopId } : {}),
};
};
const getStartAndEndOfToday = () => {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end };
};
const mapInvoice = (invoice) => ({
id: invoice.id,
invoice_number: invoice.invoice_number,
sold_at: invoice.sold_at,
total_amount: roundMoney(invoice.total_amount),
total_profit_amount: roundMoney(invoice.total_profit_amount),
payment_method: invoice.payment_method,
item_count: (invoice.sales_invoice_items_invoice || []).reduce(
(sum, item) => sum + toNumber(item.quantity, 0),
0,
),
cashier_name: [invoice.cashier?.firstName, invoice.cashier?.lastName]
.filter(Boolean)
.join(' ')
.trim(),
});
module.exports = class PosService {
static async getWorkspace(currentUser, shopId) {
const shops = await db.shops.findAll({
where: buildShopWhere(currentUser),
attributes: [
'id',
'shop_name',
'currency_name',
'usd_rate',
'allow_negative_stock',
'is_active',
'organizationsId',
],
order: [['createdAt', 'ASC']],
});
if (!shops.length) {
return {
shops: [],
selectedShop: null,
categories: [],
products: [],
summary: {
totalSales: 0,
totalProfit: 0,
invoiceCount: 0,
},
recentInvoices: [],
latestPriceChange: null,
};
}
const selectedShop =
shops.find((shop) => shop.id === shopId) ||
shops[0];
const categories = await db.categories.findAll({
where: {
shopId: selectedShop.id,
...buildOrgWhere(currentUser),
},
attributes: ['id', 'category_name', 'description'],
order: [
['sort_order', 'ASC'],
['category_name', 'ASC'],
],
});
const products = await db.products.findAll({
where: {
shopId: selectedShop.id,
...buildOrgWhere(currentUser),
},
attributes: [
'id',
'product_name',
'sku',
'barcode',
'cost_price',
'sale_price',
'sale_price_backup',
'usd_price',
'stock_quantity',
'is_active',
'categoryId',
],
include: [
{
model: db.categories,
as: 'category',
attributes: ['id', 'category_name'],
},
],
order: [
['categoryId', 'ASC'],
['product_name', 'ASC'],
],
});
const { start, end } = getStartAndEndOfToday();
const invoiceWhere = {
shopId: selectedShop.id,
...buildOrgWhere(currentUser),
sold_at: {
[Op.gte]: start,
[Op.lt]: end,
},
status: 'paid',
};
const summaryInvoices = await db.sales_invoices.findAll({
where: invoiceWhere,
attributes: ['id', 'total_amount', 'total_profit_amount'],
});
const invoices = await db.sales_invoices.findAll({
where: invoiceWhere,
attributes: [
'id',
'invoice_number',
'sold_at',
'total_amount',
'total_profit_amount',
'payment_method',
],
include: [
{
model: db.sales_invoice_items,
as: 'sales_invoice_items_invoice',
attributes: ['id', 'quantity'],
},
{
model: db.users,
as: 'cashier',
attributes: ['id', 'firstName', 'lastName'],
},
],
order: [['sold_at', 'DESC']],
limit: 12,
});
const summary = summaryInvoices.reduce(
(acc, invoice) => ({
totalSales: acc.totalSales + toNumber(invoice.total_amount),
totalProfit: acc.totalProfit + toNumber(invoice.total_profit_amount),
invoiceCount: acc.invoiceCount + 1,
}),
{ totalSales: 0, totalProfit: 0, invoiceCount: 0 },
);
const latestPriceChange = await db.price_change_logs.findOne({
where: {
shopId: selectedShop.id,
...buildOrgWhere(currentUser),
},
order: [
['changed_at', 'DESC'],
['createdAt', 'DESC'],
],
});
return {
shops: shops.map((shop) => ({
id: shop.id,
shop_name: shop.shop_name,
currency_name: shop.currency_name,
usd_rate: roundMoney(shop.usd_rate),
allow_negative_stock: Boolean(shop.allow_negative_stock),
is_active: Boolean(shop.is_active),
})),
selectedShop: {
id: selectedShop.id,
shop_name: selectedShop.shop_name,
currency_name: selectedShop.currency_name,
usd_rate: roundMoney(selectedShop.usd_rate),
allow_negative_stock: Boolean(selectedShop.allow_negative_stock),
is_active: Boolean(selectedShop.is_active),
},
categories: categories.map((category) => ({
id: category.id,
category_name: category.category_name,
description: category.description,
})),
products: products.map((product) => ({
id: product.id,
product_name: product.product_name,
sku: product.sku,
barcode: product.barcode,
cost_price: roundMoney(product.cost_price),
sale_price: roundMoney(product.sale_price),
sale_price_backup: roundMoney(product.sale_price_backup),
usd_price: product.usd_price == null ? null : roundMoney(product.usd_price),
stock_quantity: product.stock_quantity,
is_active: Boolean(product.is_active),
categoryId: product.categoryId,
category_name: product.category?.category_name || 'بدون قسم',
})),
summary: {
totalSales: roundMoney(summary.totalSales),
totalProfit: roundMoney(summary.totalProfit),
invoiceCount: summary.invoiceCount,
},
recentInvoices: invoices.map(mapInvoice),
latestPriceChange: latestPriceChange
? {
id: latestPriceChange.id,
changed_at: latestPriceChange.changed_at || latestPriceChange.createdAt,
change_type: latestPriceChange.change_type,
usd_rate_before: roundMoney(latestPriceChange.usd_rate_before),
usd_rate_after: roundMoney(latestPriceChange.usd_rate_after),
summary: latestPriceChange.summary,
}
: null,
};
}
static async checkout(currentUser, payload) {
const transaction = await db.sequelize.transaction();
try {
const rawItems = Array.isArray(payload?.items) ? payload.items : [];
const normalizedItems = rawItems
.map((item) => ({
productId: item?.productId,
quantity: Number.parseInt(String(item?.quantity ?? ''), 10),
}))
.filter((item) => item.productId && Number.isInteger(item.quantity) && item.quantity > 0);
if (!normalizedItems.length) {
throw new ValidationError('errors.validation.message');
}
const paymentMethod = String(payload?.paymentMethod || 'cash');
if (!PAYMENT_METHODS.has(paymentMethod)) {
throw new ValidationError('errors.validation.message');
}
const shop = await db.shops.findOne({
where: buildShopWhere(currentUser, payload?.shopId),
transaction,
});
if (!shop) {
throw new ValidationError('errors.validation.message');
}
const uniqueProductIds = [...new Set(normalizedItems.map((item) => item.productId))];
const products = await db.products.findAll({
where: {
id: uniqueProductIds,
shopId: shop.id,
...buildOrgWhere(currentUser),
},
transaction,
});
if (products.length !== uniqueProductIds.length) {
throw new ValidationError('errors.validation.message');
}
const productMap = new Map(products.map((product) => [product.id, product]));
let subtotal = 0;
let totalCost = 0;
const lineItems = [];
for (const item of normalizedItems) {
const product = productMap.get(item.productId);
const salePrice = toNumber(product.sale_price);
const costPrice = toNumber(product.cost_price);
const stockQuantity = product.stock_quantity;
if (
!shop.allow_negative_stock &&
stockQuantity != null &&
Number.isFinite(stockQuantity) &&
stockQuantity < item.quantity
) {
throw new Error(`الكمية غير كافية للمنتج: ${product.product_name}`);
}
const lineSubtotal = roundMoney(salePrice * item.quantity);
const lineCost = roundMoney(costPrice * item.quantity);
const lineProfit = roundMoney(lineSubtotal - lineCost);
subtotal += lineSubtotal;
totalCost += lineCost;
lineItems.push({
product,
quantity: item.quantity,
lineSubtotal,
lineProfit,
});
}
const totalAmount = roundMoney(subtotal);
const totalCostAmount = roundMoney(totalCost);
const totalProfitAmount = roundMoney(totalAmount - totalCostAmount);
const soldAt = new Date();
const invoice = await db.sales_invoices.create(
{
invoice_number: formatInvoiceNumber(),
sold_at: soldAt,
status: 'paid',
subtotal_amount: totalAmount,
discount_amount: 0,
total_amount: totalAmount,
total_cost_amount: totalCostAmount,
total_profit_amount: totalProfitAmount,
payment_method: paymentMethod,
notes: payload?.notes || null,
shopId: shop.id,
cashierId: currentUser.id,
organizationsId: currentUser.organizationsId || shop.organizationsId || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await db.sales_invoice_items.bulkCreate(
lineItems.map((item) => ({
product_name_snapshot: item.product.product_name,
cost_price_snapshot: roundMoney(item.product.cost_price),
sale_price_snapshot: roundMoney(item.product.sale_price),
quantity: item.quantity,
line_subtotal: item.lineSubtotal,
line_profit: item.lineProfit,
invoiceId: invoice.id,
productId: item.product.id,
organizationsId: currentUser.organizationsId || shop.organizationsId || null,
createdById: currentUser.id,
updatedById: currentUser.id,
})),
{ transaction },
);
for (const item of lineItems) {
if (item.product.stock_quantity != null) {
await item.product.update(
{
stock_quantity: toNumber(item.product.stock_quantity) - item.quantity,
updatedById: currentUser.id,
},
{ transaction },
);
}
}
await transaction.commit();
return {
id: invoice.id,
invoice_number: invoice.invoice_number,
sold_at: soldAt,
total_amount: totalAmount,
total_profit_amount: totalProfitAmount,
items_count: lineItems.reduce((sum, item) => sum + item.quantity, 0),
};
} catch (error) {
await transaction.rollback();
console.error('POS checkout failed:', error);
throw error;
}
}
static async updatePricing(currentUser, payload) {
const transaction = await db.sequelize.transaction();
try {
const action = String(payload?.action || '');
if (!PRICING_ACTIONS.has(action)) {
throw new ValidationError('errors.validation.message');
}
const shop = await db.shops.findOne({
where: buildShopWhere(currentUser, payload?.shopId),
transaction,
});
if (!shop) {
throw new ValidationError('errors.validation.message');
}
const existingRate = toNumber(shop.usd_rate, 0);
const incomingRate = toNumber(payload?.usdRate, existingRate);
if ((action === 'set_rate' || action === 'apply_prices') && incomingRate <= 0) {
throw new Error('يرجى إدخال سعر دولار صحيح أكبر من صفر.');
}
const products = await db.products.findAll({
where: {
shopId: shop.id,
...buildOrgWhere(currentUser),
},
transaction,
});
if (action === 'set_rate' || action === 'apply_prices') {
await shop.update(
{
usd_rate: incomingRate,
updatedById: currentUser.id,
},
{ transaction },
);
}
let changedProducts = 0;
let message = '';
let changeType = 'usd_rate_update';
if (action === 'apply_prices') {
changeType = 'bulk_increase_by_usd';
for (const product of products) {
const currentSalePrice = toNumber(product.sale_price, 0);
const baseUsdPrice =
product.usd_price != null
? toNumber(product.usd_price, 0)
: existingRate > 0
? roundMoney(currentSalePrice / existingRate)
: 0;
const updatedSalePrice = roundMoney(baseUsdPrice * incomingRate);
await product.update(
{
sale_price_backup: currentSalePrice,
usd_price: baseUsdPrice,
sale_price: updatedSalePrice,
updatedById: currentUser.id,
},
{ transaction },
);
changedProducts += 1;
}
message = `تم تحديث أسعار البيع لعدد ${changedProducts} منتج حسب سعر الدولار.`;
}
if (action === 'restore_prices') {
changeType = 'bulk_restore_previous';
for (const product of products) {
if (product.sale_price_backup == null) {
continue;
}
await product.update(
{
sale_price: product.sale_price_backup,
sale_price_backup: null,
updatedById: currentUser.id,
},
{ transaction },
);
changedProducts += 1;
}
message = changedProducts
? `تمت إعادة ${changedProducts} سعر إلى القيمة السابقة.`
: 'لا توجد أسعار محفوظة للاسترجاع حالياً.';
}
if (action === 'set_rate') {
message = `تم حفظ سعر الدولار الجديد للمحل بنجاح.`;
}
await db.price_change_logs.create(
{
changed_at: new Date(),
change_type: changeType,
usd_rate_before: existingRate || null,
usd_rate_after: action === 'restore_prices' ? existingRate || null : incomingRate,
summary: message,
shopId: shop.id,
changed_byId: currentUser.id,
organizationsId: currentUser.organizationsId || shop.organizationsId || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await transaction.commit();
return {
success: true,
action,
shopId: shop.id,
usdRate: action === 'restore_prices' ? roundMoney(shop.usd_rate) : roundMoney(incomingRate),
changedProducts,
message,
};
} catch (error) {
await transaction.rollback();
console.error('POS pricing update failed:', error);
throw error;
}
}
};