561 lines
16 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
};
|