MukeshKapoor25 commited on
Commit
733dc16
·
1 Parent(s): 7b5d36c

feat(stock): migrate to PostgreSQL stored procedures for stock management

Browse files

- Replace in-application stock ledger logic with PostgreSQL stored procedures (apply_stock_movement, apply_bulk_stock_movements)
- Rename Procurement.batch field to batch_managed for clarity
- Simplify StockService to act as validation layer calling stored procedures instead of direct ORM operations
- Update stock transaction processing to use parameterized SQL queries with stored procedure calls
- Add JSON serialization for bulk movement operations to stored procedures
- Remove direct ScmStock and ScmStockLedger model dependencies from service layer
- Update stock_utils validation functions to work with new stored procedure approach
- Add salon test catalogue data fixture for testing
- Add stock_management.sql with stored procedure definitions
- Improves data consistency by centralizing stock mutation logic in database layer

app/catalogues/models/model.py CHANGED
@@ -47,7 +47,7 @@ class Loyalty(BaseModel):
47
 
48
  class Procurement(BaseModel):
49
  moq: Optional[int] = None
50
- batch:Optional[bool]= False
51
  expiry_period_months: Optional[int] = 36
52
 
53
 
 
47
 
48
  class Procurement(BaseModel):
49
  moq: Optional[int] = None
50
+ batch_managed:Optional[bool]= False
51
  expiry_period_months: Optional[int] = 36
52
 
53
 
app/services/stock_service.py CHANGED
@@ -1,18 +1,17 @@
1
  """
2
- Generic Stock Management Service for SCM.
3
- Handles stock ledger entries and stock snapshot updates for all inventory transactions.
4
  """
5
  from typing import List, Optional, Dict, Any, Tuple
6
  from sqlalchemy.ext.asyncio import AsyncSession
7
- from sqlalchemy import select, and_, func
8
- from sqlalchemy.dialects.postgresql import insert
9
- from uuid import uuid4
10
  from decimal import Decimal
11
- from datetime import datetime
12
  from enum import Enum
 
13
  import logging
14
 
15
- from app.models.inventory_model import ScmStock, ScmStockLedger
16
  from app.models.po_grn_model import ScmGrnItem
17
 
18
  logger = logging.getLogger(__name__)
@@ -75,25 +74,45 @@ class StockTransaction:
75
 
76
 
77
  class StockService:
78
- """Generic stock management service"""
79
 
80
  def __init__(self, db: AsyncSession):
81
  self.db = db
82
 
83
  async def process_stock_transaction(self, transaction: StockTransaction) -> Tuple[str, bool]:
84
  """
85
- Process a single stock transaction.
86
- Creates ledger entry and updates stock snapshot.
87
 
88
  Returns:
89
- Tuple of (ledger_id, success)
90
  """
91
  try:
92
- # Create ledger entry
93
- ledger_id = await self._create_ledger_entry(transaction)
94
 
95
- # Update stock snapshot
96
- await self._update_stock_snapshot(transaction)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  logger.info(
99
  f"Stock transaction processed: {transaction.txn_type} "
@@ -101,7 +120,7 @@ class StockService:
101
  f"REF={transaction.ref_type}:{transaction.ref_id}"
102
  )
103
 
104
- return ledger_id, True
105
 
106
  except Exception as e:
107
  logger.error(f"Error processing stock transaction: {e}")
@@ -109,24 +128,57 @@ class StockService:
109
 
110
  async def process_stock_transactions(self, transactions: List[StockTransaction]) -> List[Tuple[str, bool]]:
111
  """
112
- Process multiple stock transactions in a single database transaction.
113
  All succeed or all fail (atomic operation).
114
 
115
  Returns:
116
  List of (ledger_id, success) tuples
117
  """
118
- results = []
119
-
120
  try:
 
121
  for transaction in transactions:
122
- ledger_id, success = await self.process_stock_transaction(transaction)
123
- results.append((ledger_id, success))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
- # Commit all transactions
 
 
 
 
 
 
 
 
126
  await self.db.commit()
127
 
128
  logger.info(f"Processed {len(transactions)} stock transactions successfully")
129
- return results
130
 
131
  except Exception as e:
132
  # Rollback on any error
@@ -134,86 +186,28 @@ class StockService:
134
  logger.error(f"Error processing stock transactions batch: {e}")
135
  raise
136
 
137
- async def _create_ledger_entry(self, transaction: StockTransaction) -> str:
138
- """Create immutable ledger entry"""
139
- ledger_entry = ScmStockLedger(
140
- ledger_id=uuid4(),
141
- merchant_id=transaction.merchant_id,
142
- location_id=transaction.location_id,
143
- catalogue_id=transaction.catalogue_id,
144
- sku=transaction.sku,
145
- batch_no=transaction.batch_no,
146
- exp_dt=transaction.exp_dt,
147
- txn_type=transaction.txn_type.value,
148
- qty=transaction.qty,
149
- uom=transaction.uom,
150
- ref_type=transaction.ref_type.value,
151
- ref_id=transaction.ref_id,
152
- ref_no=transaction.ref_no,
153
- remarks=transaction.remarks,
154
- created_by=transaction.created_by,
155
- created_at=datetime.utcnow()
156
- )
157
 
158
- self.db.add(ledger_entry)
159
- await self.db.flush() # Get the ID without committing
160
 
161
- return str(ledger_entry.ledger_id)
162
-
163
- async def _update_stock_snapshot(self, transaction: StockTransaction):
164
- """Update or create stock snapshot record"""
165
- # Find existing stock record
166
- stock_query = select(ScmStock).where(
167
- and_(
168
- ScmStock.merchant_id == transaction.merchant_id,
169
- ScmStock.location_id == transaction.location_id,
170
- ScmStock.sku == transaction.sku,
171
- ScmStock.batch_no == transaction.batch_no
172
- )
173
- )
174
 
175
- result = await self.db.execute(stock_query)
176
- existing_stock = result.scalar_one_or_none()
177
 
178
- if existing_stock:
179
- # Update existing stock
180
- new_qty_on_hand = existing_stock.qty_on_hand + transaction.qty
181
- new_qty_available = new_qty_on_hand - existing_stock.qty_reserved
182
-
183
- # Ensure quantities don't go negative
184
- if new_qty_on_hand < 0:
185
- raise ValueError(
186
- f"Insufficient stock: SKU={transaction.sku} "
187
- f"Current={existing_stock.qty_on_hand} Required={abs(transaction.qty)}"
188
- )
189
-
190
- existing_stock.qty_on_hand = new_qty_on_hand
191
- existing_stock.qty_available = max(new_qty_available, Decimal(0))
192
- existing_stock.last_updated_at = datetime.utcnow()
193
-
194
- else:
195
- # Create new stock record (for IN transactions only)
196
- if transaction.qty < 0:
197
- raise ValueError(
198
- f"Cannot create negative stock: SKU={transaction.sku} QTY={transaction.qty}"
199
- )
200
-
201
- new_stock = ScmStock(
202
- stock_id=uuid4(),
203
- merchant_id=transaction.merchant_id,
204
- location_id=transaction.location_id,
205
- catalogue_id=transaction.catalogue_id,
206
- sku=transaction.sku,
207
- batch_no=transaction.batch_no,
208
- qty_on_hand=transaction.qty,
209
- qty_reserved=Decimal(0),
210
- qty_available=transaction.qty,
211
- uom=transaction.uom,
212
- last_updated_at=datetime.utcnow(),
213
- created_at=datetime.utcnow()
214
- )
215
-
216
- self.db.add(new_stock)
217
 
218
  async def process_grn_completion(self, grn_id: str, completed_by: str) -> List[Tuple[str, bool]]:
219
  """
@@ -227,42 +221,46 @@ class StockService:
227
  List of (ledger_id, success) tuples for each item
228
  """
229
  try:
230
- # Get all GRN items for this GRN
231
- grn_items_query = select(ScmGrnItem).where(ScmGrnItem.grn_id == grn_id)
232
- result = await self.db.execute(grn_items_query)
233
- grn_items = result.scalars().all()
 
 
 
 
 
 
 
 
234
 
235
  if not grn_items:
236
- raise ValueError(f"No GRN items found for GRN: {grn_id}")
 
237
 
238
  # Create stock transactions for each accepted item
239
  transactions = []
240
 
241
  for item in grn_items:
242
- if item.acc_qty > 0: # Only process accepted quantities
243
- transaction = StockTransaction(
244
- merchant_id=item.grn.purchase_order.buyer_id, # Assuming buyer_id is merchant_id
245
- location_id=item.grn.wh_location or "default",
246
- catalogue_id=str(item.catalogue_id),
247
- sku=item.sku,
248
- batch_no=item.batch_no,
249
- exp_dt=item.exp_dt,
250
- qty=item.acc_qty, # Positive quantity for stock IN
251
- uom=item.uom,
252
- txn_type=TransactionType.GRN_IN,
253
- ref_type=ReferenceType.GRN,
254
- ref_id=str(grn_id),
255
- ref_no=item.grn.grn_no,
256
- remarks=f"GRN completion - Accepted: {item.acc_qty}, Rejected: {item.rej_qty}",
257
- created_by=completed_by
258
- )
259
- transactions.append(transaction)
260
-
261
- if not transactions:
262
- logger.warning(f"No accepted items found in GRN: {grn_id}")
263
- return []
264
 
265
- # Process all transactions atomically
266
  results = await self.process_stock_transactions(transactions)
267
 
268
  logger.info(f"GRN {grn_id} completion processed: {len(results)} stock entries created")
@@ -285,50 +283,60 @@ class StockService:
285
  ref_id: str
286
  ) -> Tuple[str, bool]:
287
  """
288
- Process stock adjustment (positive or negative).
289
 
290
  Args:
291
  adjustment_qty: Positive for increase, negative for decrease
292
  """
293
- txn_type = TransactionType.ADJUST_IN if adjustment_qty > 0 else TransactionType.ADJUST_OUT
294
-
295
- # Get catalogue_id from existing stock or require it as parameter
296
- stock_query = select(ScmStock).where(
297
- and_(
298
- ScmStock.merchant_id == merchant_id,
299
- ScmStock.location_id == location_id,
300
- ScmStock.sku == sku,
301
- ScmStock.batch_no == batch_no
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  )
303
- )
304
-
305
- result = await self.db.execute(stock_query)
306
- existing_stock = result.scalar_one_or_none()
307
-
308
- if not existing_stock and adjustment_qty < 0:
309
- raise ValueError(f"Cannot adjust non-existent stock: SKU={sku}")
310
-
311
- catalogue_id = str(existing_stock.catalogue_id) if existing_stock else None
312
- if not catalogue_id:
313
- raise ValueError(f"Catalogue ID required for new stock: SKU={sku}")
314
-
315
- transaction = StockTransaction(
316
- merchant_id=merchant_id,
317
- location_id=location_id,
318
- catalogue_id=catalogue_id,
319
- sku=sku,
320
- batch_no=batch_no,
321
- exp_dt=None, # Adjustments don't change expiry
322
- qty=adjustment_qty,
323
- uom=existing_stock.uom if existing_stock else "PCS",
324
- txn_type=txn_type,
325
- ref_type=ReferenceType.ADJUSTMENT,
326
- ref_id=ref_id,
327
- remarks=f"{adjustment_type}: {reason}",
328
- created_by=adjusted_by
329
- )
330
-
331
- return await self.process_stock_transaction(transaction)
332
 
333
  async def get_stock_summary(
334
  self,
@@ -336,31 +344,36 @@ class StockService:
336
  location_id: Optional[str] = None,
337
  sku: Optional[str] = None
338
  ) -> List[Dict[str, Any]]:
339
- """Get current stock summary with filters"""
340
- query = select(ScmStock).where(ScmStock.merchant_id == merchant_id)
341
-
342
- if location_id:
343
- query = query.where(ScmStock.location_id == location_id)
344
-
345
- if sku:
346
- query = query.where(ScmStock.sku == sku)
347
-
348
- result = await self.db.execute(query)
349
- stocks = result.scalars().all()
350
-
351
- return [
352
- {
353
- "stock_id": str(stock.stock_id),
354
- "location_id": stock.location_id,
355
- "sku": stock.sku,
356
- "batch_no": stock.batch_no,
357
- "qty_on_hand": float(stock.qty_on_hand),
358
- "qty_reserved": float(stock.qty_reserved),
359
- "qty_available": float(stock.qty_available),
360
- "last_updated": stock.last_updated_at.isoformat()
361
- }
362
- for stock in stocks
363
- ]
 
 
 
 
 
364
 
365
  async def get_stock_ledger(
366
  self,
@@ -369,34 +382,112 @@ class StockService:
369
  batch_no: Optional[str] = None,
370
  limit: int = 100
371
  ) -> List[Dict[str, Any]]:
372
- """Get stock ledger entries with filters"""
373
- query = select(ScmStockLedger).where(ScmStockLedger.merchant_id == merchant_id)
374
-
375
- if sku:
376
- query = query.where(ScmStockLedger.sku == sku)
377
-
378
- if batch_no:
379
- query = query.where(ScmStockLedger.batch_no == batch_no)
380
-
381
- query = query.order_by(ScmStockLedger.created_at.desc()).limit(limit)
382
-
383
- result = await self.db.execute(query)
384
- ledger_entries = result.scalars().all()
385
-
386
- return [
387
- {
388
- "ledger_id": str(entry.ledger_id),
389
- "location_id": entry.location_id,
390
- "sku": entry.sku,
391
- "batch_no": entry.batch_no,
392
- "txn_type": entry.txn_type,
393
- "qty": float(entry.qty),
394
- "ref_type": entry.ref_type,
395
- "ref_id": str(entry.ref_id),
396
- "ref_no": entry.ref_no,
397
- "remarks": entry.remarks,
398
- "created_at": entry.created_at.isoformat(),
399
- "created_by": entry.created_by
400
- }
401
- for entry in ledger_entries
402
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Stock Management Service using PostgreSQL Stored Procedures.
3
+ Provides validation layer and calls stored procedures for inventory mutations.
4
  """
5
  from typing import List, Optional, Dict, Any, Tuple
6
  from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy import text, select
8
+ from uuid import UUID
 
9
  from decimal import Decimal
10
+ from datetime import datetime, date
11
  from enum import Enum
12
+ import json
13
  import logging
14
 
 
15
  from app.models.po_grn_model import ScmGrnItem
16
 
17
  logger = logging.getLogger(__name__)
 
74
 
75
 
76
  class StockService:
77
+ """Stock management service using PostgreSQL stored procedures"""
78
 
79
  def __init__(self, db: AsyncSession):
80
  self.db = db
81
 
82
  async def process_stock_transaction(self, transaction: StockTransaction) -> Tuple[str, bool]:
83
  """
84
+ Process a single stock transaction using simplified stored procedure.
 
85
 
86
  Returns:
87
+ Tuple of (transaction_id, success) - transaction_id is generated for tracking
88
  """
89
  try:
90
+ # Validate transaction data
91
+ self._validate_transaction(transaction)
92
 
93
+ # Call simplified stored procedure
94
+ query = text("""
95
+ CALL apply_stock_movement(
96
+ :merchant_id, :location_id, :sku, :batch_no, :qty,
97
+ :txn_type, :ref_type, :ref_id, :ref_no, :created_by
98
+ )
99
+ """)
100
+
101
+ await self.db.execute(query, {
102
+ "merchant_id": transaction.merchant_id,
103
+ "location_id": transaction.location_id,
104
+ "sku": transaction.sku,
105
+ "batch_no": transaction.batch_no,
106
+ "qty": float(transaction.qty),
107
+ "txn_type": transaction.txn_type.value,
108
+ "ref_type": transaction.ref_type.value,
109
+ "ref_id": str(transaction.ref_id),
110
+ "ref_no": transaction.ref_no,
111
+ "created_by": transaction.created_by
112
+ })
113
+
114
+ # Generate transaction ID for tracking (since procedure returns VOID)
115
+ transaction_id = f"{transaction.ref_type.value}_{transaction.ref_id}_{transaction.sku}_{transaction.batch_no}"
116
 
117
  logger.info(
118
  f"Stock transaction processed: {transaction.txn_type} "
 
120
  f"REF={transaction.ref_type}:{transaction.ref_id}"
121
  )
122
 
123
+ return transaction_id, True
124
 
125
  except Exception as e:
126
  logger.error(f"Error processing stock transaction: {e}")
 
128
 
129
  async def process_stock_transactions(self, transactions: List[StockTransaction]) -> List[Tuple[str, bool]]:
130
  """
131
+ Process multiple stock transactions using bulk stored procedure.
132
  All succeed or all fail (atomic operation).
133
 
134
  Returns:
135
  List of (ledger_id, success) tuples
136
  """
 
 
137
  try:
138
+ # Validate all transactions
139
  for transaction in transactions:
140
+ self._validate_transaction(transaction)
141
+
142
+ # Prepare movements array for stored procedure
143
+ movements = []
144
+ for transaction in transactions:
145
+ movement = {
146
+ "merchant_id": transaction.merchant_id,
147
+ "location_id": transaction.location_id,
148
+ "catalogue_id": str(transaction.catalogue_id),
149
+ "sku": transaction.sku,
150
+ "batch_no": transaction.batch_no,
151
+ "exp_dt": transaction.exp_dt.isoformat() if transaction.exp_dt else None,
152
+ "qty": str(transaction.qty),
153
+ "uom": transaction.uom,
154
+ "txn_type": transaction.txn_type.value,
155
+ "ref_type": transaction.ref_type.value,
156
+ "ref_id": str(transaction.ref_id),
157
+ "ref_no": transaction.ref_no,
158
+ "remarks": transaction.remarks,
159
+ "created_by": transaction.created_by
160
+ }
161
+ movements.append(movement)
162
+
163
+ # Call bulk stored procedure
164
+ query = text("SELECT apply_bulk_stock_movements(:movements) as results")
165
+ result = await self.db.execute(query, {
166
+ "movements": json.dumps(movements)
167
+ })
168
 
169
+ results_json = result.scalar()
170
+ results = json.loads(results_json) if results_json else []
171
+
172
+ # Convert to expected format
173
+ processed_results = []
174
+ for result_item in results:
175
+ processed_results.append((result_item["ledger_id"], result_item["success"]))
176
+
177
+ # Commit transaction
178
  await self.db.commit()
179
 
180
  logger.info(f"Processed {len(transactions)} stock transactions successfully")
181
+ return processed_results
182
 
183
  except Exception as e:
184
  # Rollback on any error
 
186
  logger.error(f"Error processing stock transactions batch: {e}")
187
  raise
188
 
189
+ def _validate_transaction(self, transaction: StockTransaction):
190
+ """Validate transaction data before processing"""
191
+ if not transaction.merchant_id:
192
+ raise ValueError("merchant_id is required")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
+ if not transaction.location_id:
195
+ raise ValueError("location_id is required")
196
 
197
+ if not transaction.sku:
198
+ raise ValueError("sku is required")
 
 
 
 
 
 
 
 
 
 
 
199
 
200
+ if not transaction.batch_no:
201
+ raise ValueError("batch_no is required")
202
 
203
+ if transaction.qty == 0:
204
+ raise ValueError("qty cannot be zero")
205
+
206
+ if not transaction.uom:
207
+ raise ValueError("uom is required")
208
+
209
+ if not transaction.created_by:
210
+ raise ValueError("created_by is required")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
  async def process_grn_completion(self, grn_id: str, completed_by: str) -> List[Tuple[str, bool]]:
213
  """
 
221
  List of (ledger_id, success) tuples for each item
222
  """
223
  try:
224
+ # Get all GRN items for this GRN with related data
225
+ grn_items_query = text("""
226
+ SELECT gi.catalogue_id, gi.sku, gi.batch_no, gi.exp_dt, gi.acc_qty, gi.rej_qty, gi.uom,
227
+ g.grn_no, g.wh_location, po.buyer_id as merchant_id
228
+ FROM scm_grn_item gi
229
+ JOIN scm_grn g ON gi.grn_id = g.grn_id
230
+ JOIN scm_po po ON g.po_id = po.po_id
231
+ WHERE gi.grn_id = :grn_id AND gi.acc_qty > 0
232
+ """)
233
+
234
+ result = await self.db.execute(grn_items_query, {"grn_id": grn_id})
235
+ grn_items = result.fetchall()
236
 
237
  if not grn_items:
238
+ logger.warning(f"No accepted items found in GRN: {grn_id}")
239
+ return []
240
 
241
  # Create stock transactions for each accepted item
242
  transactions = []
243
 
244
  for item in grn_items:
245
+ transaction = StockTransaction(
246
+ merchant_id=item.merchant_id,
247
+ location_id=item.wh_location or "default",
248
+ catalogue_id=str(item.catalogue_id),
249
+ sku=item.sku,
250
+ batch_no=item.batch_no,
251
+ exp_dt=item.exp_dt,
252
+ qty=Decimal(str(item.acc_qty)), # Positive quantity for stock IN
253
+ uom=item.uom,
254
+ txn_type=TransactionType.GRN_IN,
255
+ ref_type=ReferenceType.GRN,
256
+ ref_id=str(grn_id),
257
+ ref_no=item.grn_no,
258
+ remarks=f"GRN completion - Accepted: {item.acc_qty}, Rejected: {item.rej_qty}",
259
+ created_by=completed_by
260
+ )
261
+ transactions.append(transaction)
 
 
 
 
 
262
 
263
+ # Process all transactions atomically using bulk stored procedure
264
  results = await self.process_stock_transactions(transactions)
265
 
266
  logger.info(f"GRN {grn_id} completion processed: {len(results)} stock entries created")
 
283
  ref_id: str
284
  ) -> Tuple[str, bool]:
285
  """
286
+ Process stock adjustment using stored procedure.
287
 
288
  Args:
289
  adjustment_qty: Positive for increase, negative for decrease
290
  """
291
+ try:
292
+ txn_type = TransactionType.ADJUST_IN if adjustment_qty > 0 else TransactionType.ADJUST_OUT
293
+
294
+ # Get catalogue_id and uom from existing stock
295
+ stock_query = text("""
296
+ SELECT catalogue_id, uom FROM scm_stock
297
+ WHERE merchant_id = :merchant_id AND location_id = :location_id
298
+ AND sku = :sku AND batch_no = :batch_no
299
+ """)
300
+
301
+ result = await self.db.execute(stock_query, {
302
+ "merchant_id": merchant_id,
303
+ "location_id": location_id,
304
+ "sku": sku,
305
+ "batch_no": batch_no
306
+ })
307
+
308
+ stock_info = result.fetchone()
309
+
310
+ if not stock_info and adjustment_qty < 0:
311
+ raise ValueError(f"Cannot adjust non-existent stock: SKU={sku}")
312
+
313
+ if not stock_info:
314
+ raise ValueError(f"Catalogue ID and UOM required for new stock: SKU={sku}")
315
+
316
+ catalogue_id = str(stock_info.catalogue_id)
317
+ uom = stock_info.uom
318
+
319
+ transaction = StockTransaction(
320
+ merchant_id=merchant_id,
321
+ location_id=location_id,
322
+ catalogue_id=catalogue_id,
323
+ sku=sku,
324
+ batch_no=batch_no,
325
+ exp_dt=None, # Adjustments don't change expiry
326
+ qty=adjustment_qty,
327
+ uom=uom,
328
+ txn_type=txn_type,
329
+ ref_type=ReferenceType.ADJUSTMENT,
330
+ ref_id=ref_id,
331
+ remarks=f"{adjustment_type}: {reason}",
332
+ created_by=adjusted_by
333
  )
334
+
335
+ return await self.process_stock_transaction(transaction)
336
+
337
+ except Exception as e:
338
+ logger.error(f"Error processing stock adjustment: {e}")
339
+ raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
  async def get_stock_summary(
342
  self,
 
344
  location_id: Optional[str] = None,
345
  sku: Optional[str] = None
346
  ) -> List[Dict[str, Any]]:
347
+ """Get current stock summary using stored procedure"""
348
+ try:
349
+ query = text("SELECT * FROM get_stock_summary(:merchant_id, :location_id, :sku)")
350
+ result = await self.db.execute(query, {
351
+ "merchant_id": merchant_id,
352
+ "location_id": location_id,
353
+ "sku": sku
354
+ })
355
+
356
+ stocks = result.fetchall()
357
+
358
+ return [
359
+ {
360
+ "stock_id": str(stock.stock_id),
361
+ "location_id": stock.location_id,
362
+ "catalogue_id": str(stock.catalogue_id),
363
+ "sku": stock.sku,
364
+ "batch_no": stock.batch_no,
365
+ "qty_on_hand": float(stock.qty_on_hand),
366
+ "qty_reserved": float(stock.qty_reserved),
367
+ "qty_available": float(stock.qty_available),
368
+ "uom": stock.uom,
369
+ "last_updated": stock.last_updated_at.isoformat()
370
+ }
371
+ for stock in stocks
372
+ ]
373
+
374
+ except Exception as e:
375
+ logger.error(f"Error getting stock summary: {e}")
376
+ raise
377
 
378
  async def get_stock_ledger(
379
  self,
 
382
  batch_no: Optional[str] = None,
383
  limit: int = 100
384
  ) -> List[Dict[str, Any]]:
385
+ """Get stock ledger entries using stored procedure"""
386
+ try:
387
+ query = text("SELECT * FROM get_stock_ledger(:merchant_id, :sku, :batch_no, :limit)")
388
+ result = await self.db.execute(query, {
389
+ "merchant_id": merchant_id,
390
+ "sku": sku,
391
+ "batch_no": batch_no,
392
+ "limit": limit
393
+ })
394
+
395
+ ledger_entries = result.fetchall()
396
+
397
+ return [
398
+ {
399
+ "ledger_id": str(entry.ledger_id),
400
+ "location_id": entry.location_id,
401
+ "catalogue_id": str(entry.catalogue_id),
402
+ "sku": entry.sku,
403
+ "batch_no": entry.batch_no,
404
+ "txn_type": entry.txn_type,
405
+ "qty": float(entry.qty),
406
+ "uom": entry.uom,
407
+ "ref_type": entry.ref_type,
408
+ "ref_id": str(entry.ref_id),
409
+ "ref_no": entry.ref_no,
410
+ "remarks": entry.remarks,
411
+ "created_by": entry.created_by,
412
+ "created_at": entry.created_at.isoformat()
413
+ }
414
+ for entry in ledger_entries
415
+ ]
416
+
417
+ except Exception as e:
418
+ logger.error(f"Error getting stock ledger: {e}")
419
+ raise
420
+
421
+ async def reserve_stock(
422
+ self,
423
+ merchant_id: str,
424
+ location_id: str,
425
+ sku: str,
426
+ batch_no: str,
427
+ qty: Decimal,
428
+ ref_id: str,
429
+ created_by: str
430
+ ) -> bool:
431
+ """Reserve stock using stored procedure"""
432
+ try:
433
+ query = text("""
434
+ SELECT reserve_stock(:merchant_id, :location_id, :sku, :batch_no, :qty, :ref_id, :created_by)
435
+ """)
436
+
437
+ result = await self.db.execute(query, {
438
+ "merchant_id": merchant_id,
439
+ "location_id": location_id,
440
+ "sku": sku,
441
+ "batch_no": batch_no,
442
+ "qty": float(qty),
443
+ "ref_id": ref_id,
444
+ "created_by": created_by
445
+ })
446
+
447
+ success = result.scalar()
448
+ await self.db.commit()
449
+
450
+ logger.info(f"Stock reserved: SKU={sku} QTY={qty} REF={ref_id}")
451
+ return success
452
+
453
+ except Exception as e:
454
+ await self.db.rollback()
455
+ logger.error(f"Error reserving stock: {e}")
456
+ raise
457
+
458
+ async def release_stock_reservation(
459
+ self,
460
+ merchant_id: str,
461
+ location_id: str,
462
+ sku: str,
463
+ batch_no: str,
464
+ qty: Decimal,
465
+ ref_id: str,
466
+ created_by: str
467
+ ) -> bool:
468
+ """Release stock reservation using stored procedure"""
469
+ try:
470
+ query = text("""
471
+ SELECT release_stock_reservation(:merchant_id, :location_id, :sku, :batch_no, :qty, :ref_id, :created_by)
472
+ """)
473
+
474
+ result = await self.db.execute(query, {
475
+ "merchant_id": merchant_id,
476
+ "location_id": location_id,
477
+ "sku": sku,
478
+ "batch_no": batch_no,
479
+ "qty": float(qty),
480
+ "ref_id": ref_id,
481
+ "created_by": created_by
482
+ })
483
+
484
+ success = result.scalar()
485
+ await self.db.commit()
486
+
487
+ logger.info(f"Stock reservation released: SKU={sku} QTY={qty} REF={ref_id}")
488
+ return success
489
+
490
+ except Exception as e:
491
+ await self.db.rollback()
492
+ logger.error(f"Error releasing stock reservation: {e}")
493
+ raise
app/utils/stock_utils.py CHANGED
@@ -1,5 +1,6 @@
1
  """
2
  Stock utility functions for easy integration across SCM services.
 
3
  """
4
  from typing import List, Tuple
5
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +13,7 @@ async def process_grn_stock_update(
12
  completed_by: str
13
  ) -> List[Tuple[str, bool]]:
14
  """
15
- Convenience function to process GRN completion stock updates.
16
 
17
  Usage in GRN service:
18
  from app.utils.stock_utils import process_grn_stock_update
@@ -120,4 +121,50 @@ async def get_stock_history(
120
  history = await get_stock_history(db, merchant_id, sku="PROD001")
121
  """
122
  stock_service = StockService(db)
123
- return await stock_service.get_stock_ledger(merchant_id, sku, batch_no, limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Stock utility functions for easy integration across SCM services.
3
+ Uses PostgreSQL stored procedures for inventory mutations.
4
  """
5
  from typing import List, Tuple
6
  from sqlalchemy.ext.asyncio import AsyncSession
 
13
  completed_by: str
14
  ) -> List[Tuple[str, bool]]:
15
  """
16
+ Convenience function to process GRN completion stock updates using stored procedures.
17
 
18
  Usage in GRN service:
19
  from app.utils.stock_utils import process_grn_stock_update
 
121
  history = await get_stock_history(db, merchant_id, sku="PROD001")
122
  """
123
  stock_service = StockService(db)
124
+ return await stock_service.get_stock_ledger(merchant_id, sku, batch_no, limit)
125
+
126
+
127
+ async def reserve_stock(
128
+ db: AsyncSession,
129
+ merchant_id: str,
130
+ location_id: str,
131
+ sku: str,
132
+ batch_no: str,
133
+ qty: float,
134
+ ref_id: str,
135
+ created_by: str
136
+ ) -> bool:
137
+ """
138
+ Convenience function to reserve stock for sales orders.
139
+
140
+ Usage:
141
+ from app.utils.stock_utils import reserve_stock
142
+
143
+ # Reserve stock for sales order
144
+ success = await reserve_stock(db, merchant_id, location_id, sku,
145
+ batch_no, qty, sales_order_id, user_id)
146
+ """
147
+ from decimal import Decimal
148
+
149
+ stock_service = StockService(db)
150
+ return await stock_service.reserve_stock(
151
+ merchant_id, location_id, sku, batch_no,
152
+ Decimal(str(qty)), ref_id, created_by
153
+ )
154
+
155
+
156
+ async def release_stock_reservation(
157
+ db: AsyncSession,
158
+ merchant_id: str,
159
+ location_id: str,
160
+ sku: str,
161
+ batch_no: str,
162
+ qty: float,
163
+ ref_id: str,
164
+ created_by: str
165
+ ) -> bool:
166
+ """
167
+ Convenience function to release stock reservations.
168
+
169
+ Usage:
170
+ from app.utils.sto
examples/create_salon_test_catalogues.py CHANGED
@@ -12,13 +12,13 @@ This script creates comprehensive salon test data including:
12
  import asyncio
13
  import json
14
  import uuid
15
- from datetime import datetime, timedelta
16
  from typing import Dict, Any, List
17
  import sys
18
  import os
19
 
20
- # Add the app directory to Python path
21
- sys.path.append(os.path.join(os.path.dirname(__file__), 'app'))
22
 
23
  from motor.motor_asyncio import AsyncIOMotorClient
24
  from app.catalogues.models.model import (
@@ -180,7 +180,7 @@ class SalonCatalogueGenerator:
180
  "barcode_number": f"BC{uuid.uuid4().hex[:10].upper()}"
181
  },
182
 
183
- "attributes": product["attributes"],
184
 
185
  "pricing": {
186
  **product["pricing"],
@@ -203,9 +203,8 @@ class SalonCatalogueGenerator:
203
 
204
  "procurement": {
205
  "moq": 10,
206
- "manufacturing_date": datetime.utcnow() - timedelta(days=30),
207
- "expiry_date": datetime.utcnow() + timedelta(days=730),
208
- "vendor_ref": f"VEN-{uuid.uuid4().hex[:6].upper()}"
209
  },
210
 
211
  "inventory": {
@@ -310,7 +309,7 @@ class SalonCatalogueGenerator:
310
  "sku": f"SRV-{uuid.uuid4().hex[:8].upper()}",
311
  },
312
 
313
- "attributes": service["attributes"],
314
 
315
  "pricing": {
316
  **service["pricing"],
@@ -402,9 +401,7 @@ class SalonCatalogueGenerator:
402
  "sku": f"PKG-{uuid.uuid4().hex[:8].upper()}",
403
  },
404
 
405
- "attributes": {
406
- "services_included": ", ".join(package["services_included"])
407
- },
408
 
409
  "pricing": {
410
  **package["pricing"],
@@ -503,10 +500,6 @@ class SalonCatalogueGenerator:
503
  json_data = []
504
  for cat in all_catalogues:
505
  cat_copy = cat.copy()
506
- if cat_copy.get("procurement", {}).get("manufacturing_date"):
507
- cat_copy["procurement"]["manufacturing_date"] = cat_copy["procurement"]["manufacturing_date"].isoformat()
508
- if cat_copy.get("procurement", {}).get("expiry_date"):
509
- cat_copy["procurement"]["expiry_date"] = cat_copy["procurement"]["expiry_date"].isoformat()
510
  if cat_copy.get("meta", {}).get("created_at"):
511
  cat_copy["meta"]["created_at"] = cat_copy["meta"]["created_at"].isoformat()
512
  json_data.append(cat_copy)
 
12
  import asyncio
13
  import json
14
  import uuid
15
+ from datetime import datetime, timedelta, timezone
16
  from typing import Dict, Any, List
17
  import sys
18
  import os
19
 
20
+ # Add the parent directory to Python path to access app module
21
+ sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
22
 
23
  from motor.motor_asyncio import AsyncIOMotorClient
24
  from app.catalogues.models.model import (
 
180
  "barcode_number": f"BC{uuid.uuid4().hex[:10].upper()}"
181
  },
182
 
183
+ "attributes": Attributes(**product["attributes"]).model_dump(),
184
 
185
  "pricing": {
186
  **product["pricing"],
 
203
 
204
  "procurement": {
205
  "moq": 10,
206
+ "batch_managed": False,
207
+ "expiry_period_months": 24
 
208
  },
209
 
210
  "inventory": {
 
309
  "sku": f"SRV-{uuid.uuid4().hex[:8].upper()}",
310
  },
311
 
312
+ "attributes": Attributes(**service["attributes"]).model_dump(),
313
 
314
  "pricing": {
315
  **service["pricing"],
 
401
  "sku": f"PKG-{uuid.uuid4().hex[:8].upper()}",
402
  },
403
 
404
+ "attributes": Attributes().model_dump(),
 
 
405
 
406
  "pricing": {
407
  **package["pricing"],
 
500
  json_data = []
501
  for cat in all_catalogues:
502
  cat_copy = cat.copy()
 
 
 
 
503
  if cat_copy.get("meta", {}).get("created_at"):
504
  cat_copy["meta"]["created_at"] = cat_copy["meta"]["created_at"].isoformat()
505
  json_data.append(cat_copy)
salon_catalogues_data.json ADDED
@@ -0,0 +1,1262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "catalogue_id": "d8376e76-7279-470d-b129-a3bc3c26ffb6",
4
+ "catalogue_name": "L'Or\u00e9al Professional Shampoo - Hydrating",
5
+ "catalogue_type": "Product",
6
+ "category": "Hair Care",
7
+ "brand": "L'Or\u00e9al Professional",
8
+ "description": "Professional hydrating shampoo for dry and damaged hair",
9
+ "identifiers": {
10
+ "sku": "SKU-ED7878D9",
11
+ "ean_code": "890103018244b",
12
+ "barcode_number": "BCC6DD9A5549"
13
+ },
14
+ "attributes": {
15
+ "size": "500ml",
16
+ "variant": null,
17
+ "hair_type": "Dry/Damaged",
18
+ "skin_type": null,
19
+ "nail_type": null,
20
+ "gender": null,
21
+ "fragrance": null,
22
+ "color": null,
23
+ "finish": null
24
+ },
25
+ "pricing": {
26
+ "retail_price": 1200.0,
27
+ "mrp": 1500.0,
28
+ "trade_margin": 20.0,
29
+ "retail_margin": 20.0,
30
+ "currency": "INR",
31
+ "discount": 0.0
32
+ },
33
+ "commission": {
34
+ "enabled": true,
35
+ "type": "Percent",
36
+ "value": 5.0
37
+ },
38
+ "loyalty": {
39
+ "enabled": true,
40
+ "points": 12,
41
+ "redeem_allowed": true
42
+ },
43
+ "procurement": {
44
+ "moq": 10,
45
+ "batch_managed": false,
46
+ "expiry_period_months": 24
47
+ },
48
+ "inventory": {
49
+ "track_inventory": true,
50
+ "stock_on_hand": 50,
51
+ "ordered_quantity": 0,
52
+ "reorder_level": 10,
53
+ "unit": "PCS",
54
+ "warehouse": "Main Warehouse"
55
+ },
56
+ "tax": {
57
+ "hsn_code": "33051000",
58
+ "gst_rate": 18.0
59
+ },
60
+ "media": {
61
+ "images": [
62
+ "https://example.com/images/l'or\u00e9al-professional-shampoo---hydrating-1.jpg",
63
+ "https://example.com/images/l'or\u00e9al-professional-shampoo---hydrating-2.jpg"
64
+ ],
65
+ "banner_image": "https://example.com/banners/l'or\u00e9al-professional-shampoo---hydrating-banner.jpg"
66
+ },
67
+ "meta": {
68
+ "created_by": "system",
69
+ "created_at": "2025-12-15T04:04:52.754103",
70
+ "status": "Active"
71
+ },
72
+ "_id": "693f88e44189f9495e2f97e1"
73
+ },
74
+ {
75
+ "catalogue_id": "63dabde8-3b8a-4d49-b156-6051a7d11a67",
76
+ "catalogue_name": "Matrix Biolage Conditioner - Smoothing",
77
+ "catalogue_type": "Product",
78
+ "category": "Hair Care",
79
+ "brand": "Matrix",
80
+ "description": "Smoothing conditioner for frizzy and unmanageable hair",
81
+ "identifiers": {
82
+ "sku": "SKU-7EBA0728",
83
+ "ean_code": "8901030d61a59",
84
+ "barcode_number": "BC10C4E5723D"
85
+ },
86
+ "attributes": {
87
+ "size": "400ml",
88
+ "variant": null,
89
+ "hair_type": "Frizzy",
90
+ "skin_type": null,
91
+ "nail_type": null,
92
+ "gender": null,
93
+ "fragrance": null,
94
+ "color": null,
95
+ "finish": null
96
+ },
97
+ "pricing": {
98
+ "retail_price": 950.0,
99
+ "mrp": 1200.0,
100
+ "trade_margin": 18.0,
101
+ "retail_margin": 18.0,
102
+ "currency": "INR",
103
+ "discount": 0.0
104
+ },
105
+ "commission": {
106
+ "enabled": true,
107
+ "type": "Percent",
108
+ "value": 5.0
109
+ },
110
+ "loyalty": {
111
+ "enabled": true,
112
+ "points": 9,
113
+ "redeem_allowed": true
114
+ },
115
+ "procurement": {
116
+ "moq": 10,
117
+ "batch_managed": false,
118
+ "expiry_period_months": 24
119
+ },
120
+ "inventory": {
121
+ "track_inventory": true,
122
+ "stock_on_hand": 50,
123
+ "ordered_quantity": 0,
124
+ "reorder_level": 10,
125
+ "unit": "PCS",
126
+ "warehouse": "Main Warehouse"
127
+ },
128
+ "tax": {
129
+ "hsn_code": "33051000",
130
+ "gst_rate": 18.0
131
+ },
132
+ "media": {
133
+ "images": [
134
+ "https://example.com/images/matrix-biolage-conditioner---smoothing-1.jpg",
135
+ "https://example.com/images/matrix-biolage-conditioner---smoothing-2.jpg"
136
+ ],
137
+ "banner_image": "https://example.com/banners/matrix-biolage-conditioner---smoothing-banner.jpg"
138
+ },
139
+ "meta": {
140
+ "created_by": "system",
141
+ "created_at": "2025-12-15T04:04:52.754142",
142
+ "status": "Active"
143
+ },
144
+ "_id": "693f88e44189f9495e2f97e2"
145
+ },
146
+ {
147
+ "catalogue_id": "6f6c6810-461d-4d0a-af95-a5345e5f355d",
148
+ "catalogue_name": "Schwarzkopf Hair Color - Ash Blonde",
149
+ "catalogue_type": "Product",
150
+ "category": "Hair Color",
151
+ "brand": "Schwarzkopf",
152
+ "description": "Professional permanent hair color in ash blonde shade",
153
+ "identifiers": {
154
+ "sku": "SKU-8A61124C",
155
+ "ean_code": "8901030e40e76",
156
+ "barcode_number": "BC52F2E2BAB0"
157
+ },
158
+ "attributes": {
159
+ "size": "60ml",
160
+ "variant": null,
161
+ "hair_type": null,
162
+ "skin_type": null,
163
+ "nail_type": null,
164
+ "gender": null,
165
+ "fragrance": null,
166
+ "color": "Ash Blonde",
167
+ "finish": null
168
+ },
169
+ "pricing": {
170
+ "retail_price": 450.0,
171
+ "mrp": 600.0,
172
+ "trade_margin": 25.0,
173
+ "retail_margin": 25.0,
174
+ "currency": "INR",
175
+ "discount": 0.0
176
+ },
177
+ "commission": {
178
+ "enabled": true,
179
+ "type": "Percent",
180
+ "value": 5.0
181
+ },
182
+ "loyalty": {
183
+ "enabled": true,
184
+ "points": 4,
185
+ "redeem_allowed": true
186
+ },
187
+ "procurement": {
188
+ "moq": 10,
189
+ "batch_managed": false,
190
+ "expiry_period_months": 24
191
+ },
192
+ "inventory": {
193
+ "track_inventory": true,
194
+ "stock_on_hand": 50,
195
+ "ordered_quantity": 0,
196
+ "reorder_level": 10,
197
+ "unit": "PCS",
198
+ "warehouse": "Main Warehouse"
199
+ },
200
+ "tax": {
201
+ "hsn_code": "33051000",
202
+ "gst_rate": 18.0
203
+ },
204
+ "media": {
205
+ "images": [
206
+ "https://example.com/images/schwarzkopf-hair-color---ash-blonde-1.jpg",
207
+ "https://example.com/images/schwarzkopf-hair-color---ash-blonde-2.jpg"
208
+ ],
209
+ "banner_image": "https://example.com/banners/schwarzkopf-hair-color---ash-blonde-banner.jpg"
210
+ },
211
+ "meta": {
212
+ "created_by": "system",
213
+ "created_at": "2025-12-15T04:04:52.754162",
214
+ "status": "Active"
215
+ },
216
+ "_id": "693f88e44189f9495e2f97e3"
217
+ },
218
+ {
219
+ "catalogue_id": "917c150d-9431-4674-9132-926e3248dee3",
220
+ "catalogue_name": "Wella Hair Serum - Shine & Protection",
221
+ "catalogue_type": "Product",
222
+ "category": "Hair Treatment",
223
+ "brand": "Wella",
224
+ "description": "Lightweight serum for shine and heat protection",
225
+ "identifiers": {
226
+ "sku": "SKU-C0E97B3E",
227
+ "ean_code": "89010302a12e6",
228
+ "barcode_number": "BCD6FF3E5B29"
229
+ },
230
+ "attributes": {
231
+ "size": "100ml",
232
+ "variant": null,
233
+ "hair_type": "All Types",
234
+ "skin_type": null,
235
+ "nail_type": null,
236
+ "gender": null,
237
+ "fragrance": null,
238
+ "color": null,
239
+ "finish": null
240
+ },
241
+ "pricing": {
242
+ "retail_price": 800.0,
243
+ "mrp": 1000.0,
244
+ "trade_margin": 20.0,
245
+ "retail_margin": 20.0,
246
+ "currency": "INR",
247
+ "discount": 0.0
248
+ },
249
+ "commission": {
250
+ "enabled": true,
251
+ "type": "Percent",
252
+ "value": 5.0
253
+ },
254
+ "loyalty": {
255
+ "enabled": true,
256
+ "points": 8,
257
+ "redeem_allowed": true
258
+ },
259
+ "procurement": {
260
+ "moq": 10,
261
+ "batch_managed": false,
262
+ "expiry_period_months": 24
263
+ },
264
+ "inventory": {
265
+ "track_inventory": true,
266
+ "stock_on_hand": 50,
267
+ "ordered_quantity": 0,
268
+ "reorder_level": 10,
269
+ "unit": "PCS",
270
+ "warehouse": "Main Warehouse"
271
+ },
272
+ "tax": {
273
+ "hsn_code": "33051000",
274
+ "gst_rate": 18.0
275
+ },
276
+ "media": {
277
+ "images": [
278
+ "https://example.com/images/wella-hair-serum---shine-&-protection-1.jpg",
279
+ "https://example.com/images/wella-hair-serum---shine-&-protection-2.jpg"
280
+ ],
281
+ "banner_image": "https://example.com/banners/wella-hair-serum---shine-&-protection-banner.jpg"
282
+ },
283
+ "meta": {
284
+ "created_by": "system",
285
+ "created_at": "2025-12-15T04:04:52.754176",
286
+ "status": "Active"
287
+ },
288
+ "_id": "693f88e44189f9495e2f97e4"
289
+ },
290
+ {
291
+ "catalogue_id": "0e7aaae0-aeed-41ff-a5db-b918e954a43a",
292
+ "catalogue_name": "Olay Regenerist Face Cleanser",
293
+ "catalogue_type": "Product",
294
+ "category": "Skin Care",
295
+ "brand": "Olay",
296
+ "description": "Anti-aging face cleanser with amino-peptides",
297
+ "identifiers": {
298
+ "sku": "SKU-FC983357",
299
+ "ean_code": "8901030188ba8",
300
+ "barcode_number": "BC490EF5A83C"
301
+ },
302
+ "attributes": {
303
+ "size": "150ml",
304
+ "variant": null,
305
+ "hair_type": null,
306
+ "skin_type": "Mature",
307
+ "nail_type": null,
308
+ "gender": null,
309
+ "fragrance": null,
310
+ "color": null,
311
+ "finish": null
312
+ },
313
+ "pricing": {
314
+ "retail_price": 650.0,
315
+ "mrp": 799.0,
316
+ "trade_margin": 15.0,
317
+ "retail_margin": 15.0,
318
+ "currency": "INR",
319
+ "discount": 0.0
320
+ },
321
+ "commission": {
322
+ "enabled": true,
323
+ "type": "Percent",
324
+ "value": 5.0
325
+ },
326
+ "loyalty": {
327
+ "enabled": true,
328
+ "points": 6,
329
+ "redeem_allowed": true
330
+ },
331
+ "procurement": {
332
+ "moq": 10,
333
+ "batch_managed": false,
334
+ "expiry_period_months": 24
335
+ },
336
+ "inventory": {
337
+ "track_inventory": true,
338
+ "stock_on_hand": 50,
339
+ "ordered_quantity": 0,
340
+ "reorder_level": 10,
341
+ "unit": "PCS",
342
+ "warehouse": "Main Warehouse"
343
+ },
344
+ "tax": {
345
+ "hsn_code": "33049900",
346
+ "gst_rate": 18.0
347
+ },
348
+ "media": {
349
+ "images": [
350
+ "https://example.com/images/olay-regenerist-face-cleanser-1.jpg",
351
+ "https://example.com/images/olay-regenerist-face-cleanser-2.jpg"
352
+ ],
353
+ "banner_image": "https://example.com/banners/olay-regenerist-face-cleanser-banner.jpg"
354
+ },
355
+ "meta": {
356
+ "created_by": "system",
357
+ "created_at": "2025-12-15T04:04:52.754189",
358
+ "status": "Active"
359
+ },
360
+ "_id": "693f88e44189f9495e2f97e5"
361
+ },
362
+ {
363
+ "catalogue_id": "1965ac40-2056-4b80-9ff1-3d8d524bd084",
364
+ "catalogue_name": "Neutrogena Hydrating Face Mask",
365
+ "catalogue_type": "Product",
366
+ "category": "Face Mask",
367
+ "brand": "Neutrogena",
368
+ "description": "Intensive hydrating face mask for dry skin",
369
+ "identifiers": {
370
+ "sku": "SKU-AFBB0DC6",
371
+ "ean_code": "890103022de49",
372
+ "barcode_number": "BC51977E3095"
373
+ },
374
+ "attributes": {
375
+ "size": "50ml",
376
+ "variant": null,
377
+ "hair_type": null,
378
+ "skin_type": "Dry",
379
+ "nail_type": null,
380
+ "gender": null,
381
+ "fragrance": null,
382
+ "color": null,
383
+ "finish": null
384
+ },
385
+ "pricing": {
386
+ "retail_price": 350.0,
387
+ "mrp": 450.0,
388
+ "trade_margin": 22.0,
389
+ "retail_margin": 22.0,
390
+ "currency": "INR",
391
+ "discount": 0.0
392
+ },
393
+ "commission": {
394
+ "enabled": true,
395
+ "type": "Percent",
396
+ "value": 5.0
397
+ },
398
+ "loyalty": {
399
+ "enabled": true,
400
+ "points": 3,
401
+ "redeem_allowed": true
402
+ },
403
+ "procurement": {
404
+ "moq": 10,
405
+ "batch_managed": false,
406
+ "expiry_period_months": 24
407
+ },
408
+ "inventory": {
409
+ "track_inventory": true,
410
+ "stock_on_hand": 50,
411
+ "ordered_quantity": 0,
412
+ "reorder_level": 10,
413
+ "unit": "PCS",
414
+ "warehouse": "Main Warehouse"
415
+ },
416
+ "tax": {
417
+ "hsn_code": "33049900",
418
+ "gst_rate": 18.0
419
+ },
420
+ "media": {
421
+ "images": [
422
+ "https://example.com/images/neutrogena-hydrating-face-mask-1.jpg",
423
+ "https://example.com/images/neutrogena-hydrating-face-mask-2.jpg"
424
+ ],
425
+ "banner_image": "https://example.com/banners/neutrogena-hydrating-face-mask-banner.jpg"
426
+ },
427
+ "meta": {
428
+ "created_by": "system",
429
+ "created_at": "2025-12-15T04:04:52.754205",
430
+ "status": "Active"
431
+ },
432
+ "_id": "693f88e44189f9495e2f97e6"
433
+ },
434
+ {
435
+ "catalogue_id": "09c24693-a18e-479d-b043-4300d60c6570",
436
+ "catalogue_name": "The Body Shop Vitamin E Moisturizer",
437
+ "catalogue_type": "Product",
438
+ "category": "Moisturizer",
439
+ "brand": "The Body Shop",
440
+ "description": "Daily moisturizer with Vitamin E for all skin types",
441
+ "identifiers": {
442
+ "sku": "SKU-B7966891",
443
+ "ean_code": "8901030530f24",
444
+ "barcode_number": "BC4A3C8688A7"
445
+ },
446
+ "attributes": {
447
+ "size": "100ml",
448
+ "variant": null,
449
+ "hair_type": null,
450
+ "skin_type": "All Types",
451
+ "nail_type": null,
452
+ "gender": null,
453
+ "fragrance": null,
454
+ "color": null,
455
+ "finish": null
456
+ },
457
+ "pricing": {
458
+ "retail_price": 1200.0,
459
+ "mrp": 1495.0,
460
+ "trade_margin": 20.0,
461
+ "retail_margin": 20.0,
462
+ "currency": "INR",
463
+ "discount": 0.0
464
+ },
465
+ "commission": {
466
+ "enabled": true,
467
+ "type": "Percent",
468
+ "value": 5.0
469
+ },
470
+ "loyalty": {
471
+ "enabled": true,
472
+ "points": 12,
473
+ "redeem_allowed": true
474
+ },
475
+ "procurement": {
476
+ "moq": 10,
477
+ "batch_managed": false,
478
+ "expiry_period_months": 24
479
+ },
480
+ "inventory": {
481
+ "track_inventory": true,
482
+ "stock_on_hand": 50,
483
+ "ordered_quantity": 0,
484
+ "reorder_level": 10,
485
+ "unit": "PCS",
486
+ "warehouse": "Main Warehouse"
487
+ },
488
+ "tax": {
489
+ "hsn_code": "33049900",
490
+ "gst_rate": 18.0
491
+ },
492
+ "media": {
493
+ "images": [
494
+ "https://example.com/images/the-body-shop-vitamin-e-moisturizer-1.jpg",
495
+ "https://example.com/images/the-body-shop-vitamin-e-moisturizer-2.jpg"
496
+ ],
497
+ "banner_image": "https://example.com/banners/the-body-shop-vitamin-e-moisturizer-banner.jpg"
498
+ },
499
+ "meta": {
500
+ "created_by": "system",
501
+ "created_at": "2025-12-15T04:04:52.754217",
502
+ "status": "Active"
503
+ },
504
+ "_id": "693f88e44189f9495e2f97e7"
505
+ },
506
+ {
507
+ "catalogue_id": "209b07ad-a0b8-41b3-8c3a-627ec517d64c",
508
+ "catalogue_name": "OPI Nail Polish - Classic Red",
509
+ "catalogue_type": "Product",
510
+ "category": "Nail Care",
511
+ "brand": "OPI",
512
+ "description": "Long-lasting nail polish in classic red shade",
513
+ "identifiers": {
514
+ "sku": "SKU-6B7E72FE",
515
+ "ean_code": "8901030f609e8",
516
+ "barcode_number": "BC813A6E1E60"
517
+ },
518
+ "attributes": {
519
+ "size": "15ml",
520
+ "variant": null,
521
+ "hair_type": null,
522
+ "skin_type": null,
523
+ "nail_type": null,
524
+ "gender": null,
525
+ "fragrance": null,
526
+ "color": "Classic Red",
527
+ "finish": "Glossy"
528
+ },
529
+ "pricing": {
530
+ "retail_price": 750.0,
531
+ "mrp": 950.0,
532
+ "trade_margin": 21.0,
533
+ "retail_margin": 21.0,
534
+ "currency": "INR",
535
+ "discount": 0.0
536
+ },
537
+ "commission": {
538
+ "enabled": true,
539
+ "type": "Percent",
540
+ "value": 5.0
541
+ },
542
+ "loyalty": {
543
+ "enabled": true,
544
+ "points": 7,
545
+ "redeem_allowed": true
546
+ },
547
+ "procurement": {
548
+ "moq": 10,
549
+ "batch_managed": false,
550
+ "expiry_period_months": 24
551
+ },
552
+ "inventory": {
553
+ "track_inventory": true,
554
+ "stock_on_hand": 50,
555
+ "ordered_quantity": 0,
556
+ "reorder_level": 10,
557
+ "unit": "PCS",
558
+ "warehouse": "Main Warehouse"
559
+ },
560
+ "tax": {
561
+ "hsn_code": "33043000",
562
+ "gst_rate": 18.0
563
+ },
564
+ "media": {
565
+ "images": [
566
+ "https://example.com/images/opi-nail-polish---classic-red-1.jpg",
567
+ "https://example.com/images/opi-nail-polish---classic-red-2.jpg"
568
+ ],
569
+ "banner_image": "https://example.com/banners/opi-nail-polish---classic-red-banner.jpg"
570
+ },
571
+ "meta": {
572
+ "created_by": "system",
573
+ "created_at": "2025-12-15T04:04:52.754231",
574
+ "status": "Active"
575
+ },
576
+ "_id": "693f88e44189f9495e2f97e8"
577
+ },
578
+ {
579
+ "catalogue_id": "889923e2-3ee3-4169-809a-37f30c01bd49",
580
+ "catalogue_name": "Essie Base Coat - Strong Start",
581
+ "catalogue_type": "Product",
582
+ "category": "Nail Care",
583
+ "brand": "Essie",
584
+ "description": "Strengthening base coat for healthy nails",
585
+ "identifiers": {
586
+ "sku": "SKU-E0D8EE03",
587
+ "ean_code": "89010304da911",
588
+ "barcode_number": "BC95174F1F0C"
589
+ },
590
+ "attributes": {
591
+ "size": "13.5ml",
592
+ "variant": null,
593
+ "hair_type": null,
594
+ "skin_type": null,
595
+ "nail_type": "Weak",
596
+ "gender": null,
597
+ "fragrance": null,
598
+ "color": null,
599
+ "finish": "Clear"
600
+ },
601
+ "pricing": {
602
+ "retail_price": 550.0,
603
+ "mrp": 699.0,
604
+ "trade_margin": 21.0,
605
+ "retail_margin": 21.0,
606
+ "currency": "INR",
607
+ "discount": 0.0
608
+ },
609
+ "commission": {
610
+ "enabled": true,
611
+ "type": "Percent",
612
+ "value": 5.0
613
+ },
614
+ "loyalty": {
615
+ "enabled": true,
616
+ "points": 5,
617
+ "redeem_allowed": true
618
+ },
619
+ "procurement": {
620
+ "moq": 10,
621
+ "batch_managed": false,
622
+ "expiry_period_months": 24
623
+ },
624
+ "inventory": {
625
+ "track_inventory": true,
626
+ "stock_on_hand": 50,
627
+ "ordered_quantity": 0,
628
+ "reorder_level": 10,
629
+ "unit": "PCS",
630
+ "warehouse": "Main Warehouse"
631
+ },
632
+ "tax": {
633
+ "hsn_code": "33043000",
634
+ "gst_rate": 18.0
635
+ },
636
+ "media": {
637
+ "images": [
638
+ "https://example.com/images/essie-base-coat---strong-start-1.jpg",
639
+ "https://example.com/images/essie-base-coat---strong-start-2.jpg"
640
+ ],
641
+ "banner_image": "https://example.com/banners/essie-base-coat---strong-start-banner.jpg"
642
+ },
643
+ "meta": {
644
+ "created_by": "system",
645
+ "created_at": "2025-12-15T04:04:52.754243",
646
+ "status": "Active"
647
+ },
648
+ "_id": "693f88e44189f9495e2f97e9"
649
+ },
650
+ {
651
+ "catalogue_id": "b78c33c5-d15a-4c72-a183-4fb67d7a04f4",
652
+ "catalogue_name": "Sally Hansen Cuticle Oil",
653
+ "catalogue_type": "Product",
654
+ "category": "Nail Treatment",
655
+ "brand": "Sally Hansen",
656
+ "description": "Nourishing cuticle oil with vitamin E",
657
+ "identifiers": {
658
+ "sku": "SKU-975BB8C6",
659
+ "ean_code": "890103029066d",
660
+ "barcode_number": "BC68188D6AE4"
661
+ },
662
+ "attributes": {
663
+ "size": "11ml",
664
+ "variant": null,
665
+ "hair_type": null,
666
+ "skin_type": null,
667
+ "nail_type": "All Types",
668
+ "gender": null,
669
+ "fragrance": null,
670
+ "color": null,
671
+ "finish": null
672
+ },
673
+ "pricing": {
674
+ "retail_price": 400.0,
675
+ "mrp": 525.0,
676
+ "trade_margin": 24.0,
677
+ "retail_margin": 24.0,
678
+ "currency": "INR",
679
+ "discount": 0.0
680
+ },
681
+ "commission": {
682
+ "enabled": true,
683
+ "type": "Percent",
684
+ "value": 5.0
685
+ },
686
+ "loyalty": {
687
+ "enabled": true,
688
+ "points": 4,
689
+ "redeem_allowed": true
690
+ },
691
+ "procurement": {
692
+ "moq": 10,
693
+ "batch_managed": false,
694
+ "expiry_period_months": 24
695
+ },
696
+ "inventory": {
697
+ "track_inventory": true,
698
+ "stock_on_hand": 50,
699
+ "ordered_quantity": 0,
700
+ "reorder_level": 10,
701
+ "unit": "PCS",
702
+ "warehouse": "Main Warehouse"
703
+ },
704
+ "tax": {
705
+ "hsn_code": "33043000",
706
+ "gst_rate": 18.0
707
+ },
708
+ "media": {
709
+ "images": [
710
+ "https://example.com/images/sally-hansen-cuticle-oil-1.jpg",
711
+ "https://example.com/images/sally-hansen-cuticle-oil-2.jpg"
712
+ ],
713
+ "banner_image": "https://example.com/banners/sally-hansen-cuticle-oil-banner.jpg"
714
+ },
715
+ "meta": {
716
+ "created_by": "system",
717
+ "created_at": "2025-12-15T04:04:52.754254",
718
+ "status": "Active"
719
+ },
720
+ "_id": "693f88e44189f9495e2f97ea"
721
+ },
722
+ {
723
+ "catalogue_id": "edaf1100-f650-4dbb-9e85-f8a3c0d7ab87",
724
+ "catalogue_name": "Hair Cut & Styling - Women",
725
+ "catalogue_type": "Service",
726
+ "category": "Hair Services",
727
+ "brand": null,
728
+ "description": "Professional hair cut and styling for women",
729
+ "identifiers": {
730
+ "sku": "SRV-356C5783"
731
+ },
732
+ "attributes": {
733
+ "size": null,
734
+ "variant": null,
735
+ "hair_type": "All Types",
736
+ "skin_type": null,
737
+ "nail_type": null,
738
+ "gender": "Female",
739
+ "fragrance": null,
740
+ "color": null,
741
+ "finish": null
742
+ },
743
+ "pricing": {
744
+ "retail_price": 800.0,
745
+ "mrp": 1000.0,
746
+ "retail_margin": 40.0,
747
+ "trade_margin": 0.0,
748
+ "currency": "INR",
749
+ "discount": 0.0
750
+ },
751
+ "commission": {
752
+ "enabled": true,
753
+ "type": "Percent",
754
+ "value": 10.0
755
+ },
756
+ "loyalty": {
757
+ "enabled": true,
758
+ "points": 16,
759
+ "redeem_allowed": true
760
+ },
761
+ "inventory": {
762
+ "track_inventory": false,
763
+ "unit": "SERVICE"
764
+ },
765
+ "tax": {
766
+ "hsn_code": "99820000",
767
+ "gst_rate": 18.0
768
+ },
769
+ "media": {
770
+ "images": [
771
+ "https://example.com/services/hair-cut-&-styling---women-1.jpg"
772
+ ],
773
+ "banner_image": "https://example.com/services/hair-cut-&-styling---women-banner.jpg"
774
+ },
775
+ "meta": {
776
+ "created_by": "system",
777
+ "created_at": "2025-12-15T04:04:52.754283",
778
+ "status": "Active"
779
+ },
780
+ "_id": "693f88e44189f9495e2f97eb"
781
+ },
782
+ {
783
+ "catalogue_id": "5afddf66-7f53-4bf0-a6eb-bee47c93a133",
784
+ "catalogue_name": "Hair Cut & Styling - Men",
785
+ "catalogue_type": "Service",
786
+ "category": "Hair Services",
787
+ "brand": null,
788
+ "description": "Professional hair cut and styling for men",
789
+ "identifiers": {
790
+ "sku": "SRV-21481FBE"
791
+ },
792
+ "attributes": {
793
+ "size": null,
794
+ "variant": null,
795
+ "hair_type": "All Types",
796
+ "skin_type": null,
797
+ "nail_type": null,
798
+ "gender": "Male",
799
+ "fragrance": null,
800
+ "color": null,
801
+ "finish": null
802
+ },
803
+ "pricing": {
804
+ "retail_price": 500.0,
805
+ "mrp": 650.0,
806
+ "retail_margin": 40.0,
807
+ "trade_margin": 0.0,
808
+ "currency": "INR",
809
+ "discount": 0.0
810
+ },
811
+ "commission": {
812
+ "enabled": true,
813
+ "type": "Percent",
814
+ "value": 10.0
815
+ },
816
+ "loyalty": {
817
+ "enabled": true,
818
+ "points": 10,
819
+ "redeem_allowed": true
820
+ },
821
+ "inventory": {
822
+ "track_inventory": false,
823
+ "unit": "SERVICE"
824
+ },
825
+ "tax": {
826
+ "hsn_code": "99820000",
827
+ "gst_rate": 18.0
828
+ },
829
+ "media": {
830
+ "images": [
831
+ "https://example.com/services/hair-cut-&-styling---men-1.jpg"
832
+ ],
833
+ "banner_image": "https://example.com/services/hair-cut-&-styling---men-banner.jpg"
834
+ },
835
+ "meta": {
836
+ "created_by": "system",
837
+ "created_at": "2025-12-15T04:04:52.754302",
838
+ "status": "Active"
839
+ },
840
+ "_id": "693f88e44189f9495e2f97ec"
841
+ },
842
+ {
843
+ "catalogue_id": "5006d81d-fd12-4763-b06f-23a3d3f2718d",
844
+ "catalogue_name": "Hair Color - Full Head",
845
+ "catalogue_type": "Service",
846
+ "category": "Hair Services",
847
+ "brand": null,
848
+ "description": "Complete hair coloring service with professional products",
849
+ "identifiers": {
850
+ "sku": "SRV-77142B53"
851
+ },
852
+ "attributes": {
853
+ "size": null,
854
+ "variant": null,
855
+ "hair_type": "All Types",
856
+ "skin_type": null,
857
+ "nail_type": null,
858
+ "gender": null,
859
+ "fragrance": null,
860
+ "color": null,
861
+ "finish": null
862
+ },
863
+ "pricing": {
864
+ "retail_price": 2500.0,
865
+ "mrp": 3000.0,
866
+ "retail_margin": 40.0,
867
+ "trade_margin": 0.0,
868
+ "currency": "INR",
869
+ "discount": 0.0
870
+ },
871
+ "commission": {
872
+ "enabled": true,
873
+ "type": "Percent",
874
+ "value": 10.0
875
+ },
876
+ "loyalty": {
877
+ "enabled": true,
878
+ "points": 50,
879
+ "redeem_allowed": true
880
+ },
881
+ "inventory": {
882
+ "track_inventory": false,
883
+ "unit": "SERVICE"
884
+ },
885
+ "tax": {
886
+ "hsn_code": "99820000",
887
+ "gst_rate": 18.0
888
+ },
889
+ "media": {
890
+ "images": [
891
+ "https://example.com/services/hair-color---full-head-1.jpg"
892
+ ],
893
+ "banner_image": "https://example.com/services/hair-color---full-head-banner.jpg"
894
+ },
895
+ "meta": {
896
+ "created_by": "system",
897
+ "created_at": "2025-12-15T04:04:52.754314",
898
+ "status": "Active"
899
+ },
900
+ "_id": "693f88e44189f9495e2f97ed"
901
+ },
902
+ {
903
+ "catalogue_id": "99593e1a-d439-4b42-8d33-e5fdc16eeccc",
904
+ "catalogue_name": "Facial - Deep Cleansing",
905
+ "catalogue_type": "Service",
906
+ "category": "Skin Services",
907
+ "brand": null,
908
+ "description": "Deep cleansing facial with extraction and moisturizing",
909
+ "identifiers": {
910
+ "sku": "SRV-E32BBE70"
911
+ },
912
+ "attributes": {
913
+ "size": null,
914
+ "variant": null,
915
+ "hair_type": null,
916
+ "skin_type": "All Types",
917
+ "nail_type": null,
918
+ "gender": null,
919
+ "fragrance": null,
920
+ "color": null,
921
+ "finish": null
922
+ },
923
+ "pricing": {
924
+ "retail_price": 1200.0,
925
+ "mrp": 1500.0,
926
+ "retail_margin": 40.0,
927
+ "trade_margin": 0.0,
928
+ "currency": "INR",
929
+ "discount": 0.0
930
+ },
931
+ "commission": {
932
+ "enabled": true,
933
+ "type": "Percent",
934
+ "value": 10.0
935
+ },
936
+ "loyalty": {
937
+ "enabled": true,
938
+ "points": 24,
939
+ "redeem_allowed": true
940
+ },
941
+ "inventory": {
942
+ "track_inventory": false,
943
+ "unit": "SERVICE"
944
+ },
945
+ "tax": {
946
+ "hsn_code": "99820000",
947
+ "gst_rate": 18.0
948
+ },
949
+ "media": {
950
+ "images": [
951
+ "https://example.com/services/facial---deep-cleansing-1.jpg"
952
+ ],
953
+ "banner_image": "https://example.com/services/facial---deep-cleansing-banner.jpg"
954
+ },
955
+ "meta": {
956
+ "created_by": "system",
957
+ "created_at": "2025-12-15T04:04:52.754322",
958
+ "status": "Active"
959
+ },
960
+ "_id": "693f88e44189f9495e2f97ee"
961
+ },
962
+ {
963
+ "catalogue_id": "b452213a-d3ed-471d-b416-5cce037da8eb",
964
+ "catalogue_name": "Manicure - Classic",
965
+ "catalogue_type": "Service",
966
+ "category": "Nail Services",
967
+ "brand": null,
968
+ "description": "Classic manicure with nail shaping, cuticle care, and polish",
969
+ "identifiers": {
970
+ "sku": "SRV-65EA6C35"
971
+ },
972
+ "attributes": {
973
+ "size": null,
974
+ "variant": null,
975
+ "hair_type": null,
976
+ "skin_type": null,
977
+ "nail_type": "All Types",
978
+ "gender": null,
979
+ "fragrance": null,
980
+ "color": null,
981
+ "finish": null
982
+ },
983
+ "pricing": {
984
+ "retail_price": 600.0,
985
+ "mrp": 750.0,
986
+ "retail_margin": 40.0,
987
+ "trade_margin": 0.0,
988
+ "currency": "INR",
989
+ "discount": 0.0
990
+ },
991
+ "commission": {
992
+ "enabled": true,
993
+ "type": "Percent",
994
+ "value": 10.0
995
+ },
996
+ "loyalty": {
997
+ "enabled": true,
998
+ "points": 12,
999
+ "redeem_allowed": true
1000
+ },
1001
+ "inventory": {
1002
+ "track_inventory": false,
1003
+ "unit": "SERVICE"
1004
+ },
1005
+ "tax": {
1006
+ "hsn_code": "99820000",
1007
+ "gst_rate": 18.0
1008
+ },
1009
+ "media": {
1010
+ "images": [
1011
+ "https://example.com/services/manicure---classic-1.jpg"
1012
+ ],
1013
+ "banner_image": "https://example.com/services/manicure---classic-banner.jpg"
1014
+ },
1015
+ "meta": {
1016
+ "created_by": "system",
1017
+ "created_at": "2025-12-15T04:04:52.754331",
1018
+ "status": "Active"
1019
+ },
1020
+ "_id": "693f88e44189f9495e2f97ef"
1021
+ },
1022
+ {
1023
+ "catalogue_id": "a6786f4c-33cf-433b-bf92-fc04225be916",
1024
+ "catalogue_name": "Pedicure - Spa",
1025
+ "catalogue_type": "Service",
1026
+ "category": "Nail Services",
1027
+ "brand": null,
1028
+ "description": "Relaxing spa pedicure with foot massage and polish",
1029
+ "identifiers": {
1030
+ "sku": "SRV-98F4A078"
1031
+ },
1032
+ "attributes": {
1033
+ "size": null,
1034
+ "variant": null,
1035
+ "hair_type": null,
1036
+ "skin_type": null,
1037
+ "nail_type": "All Types",
1038
+ "gender": null,
1039
+ "fragrance": null,
1040
+ "color": null,
1041
+ "finish": null
1042
+ },
1043
+ "pricing": {
1044
+ "retail_price": 800.0,
1045
+ "mrp": 1000.0,
1046
+ "retail_margin": 40.0,
1047
+ "trade_margin": 0.0,
1048
+ "currency": "INR",
1049
+ "discount": 0.0
1050
+ },
1051
+ "commission": {
1052
+ "enabled": true,
1053
+ "type": "Percent",
1054
+ "value": 10.0
1055
+ },
1056
+ "loyalty": {
1057
+ "enabled": true,
1058
+ "points": 16,
1059
+ "redeem_allowed": true
1060
+ },
1061
+ "inventory": {
1062
+ "track_inventory": false,
1063
+ "unit": "SERVICE"
1064
+ },
1065
+ "tax": {
1066
+ "hsn_code": "99820000",
1067
+ "gst_rate": 18.0
1068
+ },
1069
+ "media": {
1070
+ "images": [
1071
+ "https://example.com/services/pedicure---spa-1.jpg"
1072
+ ],
1073
+ "banner_image": "https://example.com/services/pedicure---spa-banner.jpg"
1074
+ },
1075
+ "meta": {
1076
+ "created_by": "system",
1077
+ "created_at": "2025-12-15T04:04:52.754338",
1078
+ "status": "Active"
1079
+ },
1080
+ "_id": "693f88e44189f9495e2f97f0"
1081
+ },
1082
+ {
1083
+ "catalogue_id": "2553a8ba-d48d-4b1d-bd40-d54bc57c4e52",
1084
+ "catalogue_name": "Bridal Beauty Package",
1085
+ "catalogue_type": "Package",
1086
+ "category": "Beauty Packages",
1087
+ "brand": null,
1088
+ "description": "Complete bridal beauty package including hair, makeup, and nail services",
1089
+ "identifiers": {
1090
+ "sku": "PKG-6B180B14"
1091
+ },
1092
+ "attributes": {
1093
+ "size": null,
1094
+ "variant": null,
1095
+ "hair_type": null,
1096
+ "skin_type": null,
1097
+ "nail_type": null,
1098
+ "gender": null,
1099
+ "fragrance": null,
1100
+ "color": null,
1101
+ "finish": null
1102
+ },
1103
+ "pricing": {
1104
+ "retail_price": 8000.0,
1105
+ "mrp": 10000.0,
1106
+ "retail_margin": 45.0,
1107
+ "trade_margin": 0.0,
1108
+ "currency": "INR",
1109
+ "discount": 10.0
1110
+ },
1111
+ "commission": {
1112
+ "enabled": true,
1113
+ "type": "Percent",
1114
+ "value": 15.0
1115
+ },
1116
+ "loyalty": {
1117
+ "enabled": true,
1118
+ "points": 240,
1119
+ "redeem_allowed": true
1120
+ },
1121
+ "inventory": {
1122
+ "track_inventory": false,
1123
+ "unit": "PACKAGE"
1124
+ },
1125
+ "tax": {
1126
+ "hsn_code": "99820000",
1127
+ "gst_rate": 18.0
1128
+ },
1129
+ "media": {
1130
+ "images": [
1131
+ "https://example.com/packages/bridal-beauty-package-1.jpg"
1132
+ ],
1133
+ "banner_image": "https://example.com/packages/bridal-beauty-package-banner.jpg"
1134
+ },
1135
+ "meta": {
1136
+ "created_by": "system",
1137
+ "created_at": "2025-12-15T04:04:52.754361",
1138
+ "status": "Active"
1139
+ },
1140
+ "_id": "693f88e44189f9495e2f97f1"
1141
+ },
1142
+ {
1143
+ "catalogue_id": "3e840d3f-9597-4790-bc30-43b10a1c5292",
1144
+ "catalogue_name": "Monthly Hair Care Package",
1145
+ "catalogue_type": "Package",
1146
+ "category": "Hair Packages",
1147
+ "brand": null,
1148
+ "description": "Monthly hair care package with cut, treatment, and styling",
1149
+ "identifiers": {
1150
+ "sku": "PKG-418995E2"
1151
+ },
1152
+ "attributes": {
1153
+ "size": null,
1154
+ "variant": null,
1155
+ "hair_type": null,
1156
+ "skin_type": null,
1157
+ "nail_type": null,
1158
+ "gender": null,
1159
+ "fragrance": null,
1160
+ "color": null,
1161
+ "finish": null
1162
+ },
1163
+ "pricing": {
1164
+ "retail_price": 3000.0,
1165
+ "mrp": 3500.0,
1166
+ "retail_margin": 45.0,
1167
+ "trade_margin": 0.0,
1168
+ "currency": "INR",
1169
+ "discount": 10.0
1170
+ },
1171
+ "commission": {
1172
+ "enabled": true,
1173
+ "type": "Percent",
1174
+ "value": 15.0
1175
+ },
1176
+ "loyalty": {
1177
+ "enabled": true,
1178
+ "points": 90,
1179
+ "redeem_allowed": true
1180
+ },
1181
+ "inventory": {
1182
+ "track_inventory": false,
1183
+ "unit": "PACKAGE"
1184
+ },
1185
+ "tax": {
1186
+ "hsn_code": "99820000",
1187
+ "gst_rate": 18.0
1188
+ },
1189
+ "media": {
1190
+ "images": [
1191
+ "https://example.com/packages/monthly-hair-care-package-1.jpg"
1192
+ ],
1193
+ "banner_image": "https://example.com/packages/monthly-hair-care-package-banner.jpg"
1194
+ },
1195
+ "meta": {
1196
+ "created_by": "system",
1197
+ "created_at": "2025-12-15T04:04:52.754374",
1198
+ "status": "Active"
1199
+ },
1200
+ "_id": "693f88e44189f9495e2f97f2"
1201
+ },
1202
+ {
1203
+ "catalogue_id": "d4d54115-c091-4b14-809c-0d7367c11d63",
1204
+ "catalogue_name": "Skin Rejuvenation Package",
1205
+ "catalogue_type": "Package",
1206
+ "category": "Skin Packages",
1207
+ "brand": null,
1208
+ "description": "Complete skin rejuvenation with multiple facial treatments",
1209
+ "identifiers": {
1210
+ "sku": "PKG-4F4338FC"
1211
+ },
1212
+ "attributes": {
1213
+ "size": null,
1214
+ "variant": null,
1215
+ "hair_type": null,
1216
+ "skin_type": null,
1217
+ "nail_type": null,
1218
+ "gender": null,
1219
+ "fragrance": null,
1220
+ "color": null,
1221
+ "finish": null
1222
+ },
1223
+ "pricing": {
1224
+ "retail_price": 4500.0,
1225
+ "mrp": 5500.0,
1226
+ "retail_margin": 45.0,
1227
+ "trade_margin": 0.0,
1228
+ "currency": "INR",
1229
+ "discount": 10.0
1230
+ },
1231
+ "commission": {
1232
+ "enabled": true,
1233
+ "type": "Percent",
1234
+ "value": 15.0
1235
+ },
1236
+ "loyalty": {
1237
+ "enabled": true,
1238
+ "points": 135,
1239
+ "redeem_allowed": true
1240
+ },
1241
+ "inventory": {
1242
+ "track_inventory": false,
1243
+ "unit": "PACKAGE"
1244
+ },
1245
+ "tax": {
1246
+ "hsn_code": "99820000",
1247
+ "gst_rate": 18.0
1248
+ },
1249
+ "media": {
1250
+ "images": [
1251
+ "https://example.com/packages/skin-rejuvenation-package-1.jpg"
1252
+ ],
1253
+ "banner_image": "https://example.com/packages/skin-rejuvenation-package-banner.jpg"
1254
+ },
1255
+ "meta": {
1256
+ "created_by": "system",
1257
+ "created_at": "2025-12-15T04:04:52.754383",
1258
+ "status": "Active"
1259
+ },
1260
+ "_id": "693f88e44189f9495e2f97f3"
1261
+ }
1262
+ ]
sql/stored_procedures/stock_management.sql ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Stock Management Stored Procedures
2
+ -- Centralized inventory mutation logic in PostgreSQL
3
+
4
+ -- Simplified stock movement function with idempotency
5
+ CREATE OR REPLACE FUNCTION apply_stock_movement(
6
+ p_merchant_id TEXT,
7
+ p_location_id TEXT,
8
+ p_sku TEXT,
9
+ p_batch_no TEXT,
10
+ p_qty NUMERIC,
11
+ p_txn_type TEXT,
12
+ p_ref_type TEXT,
13
+ p_ref_id UUID,
14
+ p_ref_no TEXT,
15
+ p_user TEXT
16
+ ) RETURNS VOID AS $$
17
+ BEGIN
18
+ -- Idempotency check - prevent duplicate processing
19
+ IF EXISTS (
20
+ SELECT 1 FROM scm_stock_ledger
21
+ WHERE ref_type = p_ref_type AND ref_id = p_ref_id AND sku = p_sku AND batch_no = p_batch_no
22
+ ) THEN
23
+ RETURN;
24
+ END IF;
25
+
26
+ -- Insert ledger entry (immutable audit trail)
27
+ INSERT INTO scm_stock_ledger (
28
+ ledger_id, merchant_id, location_id, sku, batch_no,
29
+ qty, txn_type, ref_type, ref_id, ref_no, created_by
30
+ ) VALUES (
31
+ gen_random_uuid(), p_merchant_id, p_location_id, p_sku, p_batch_no,
32
+ p_qty, p_txn_type, p_ref_type, p_ref_id, p_ref_no, p_user
33
+ );
34
+
35
+ -- Stock snapshot upsert (create or update)
36
+ INSERT INTO scm_stock (
37
+ stock_id, merchant_id, location_id, sku, batch_no,
38
+ qty_on_hand, qty_reserved, qty_available
39
+ ) VALUES (
40
+ gen_random_uuid(), p_merchant_id, p_location_id, p_sku, p_batch_no,
41
+ p_qty, 0, p_qty
42
+ )
43
+ ON CONFLICT (merchant_id, location_id, sku, batch_no)
44
+ DO UPDATE SET
45
+ qty_on_hand = scm_stock.qty_on_hand + p_qty,
46
+ qty_available = scm_stock.qty_available + p_qty,
47
+ last_updated_at = NOW();
48
+ END;
49
+ $$ LANGUAGE plpgsql;
50
+
51
+ -- Function to process multiple stock movements atomically (for GRN completion)
52
+ CREATE OR REPLACE FUNCTION apply_bulk_stock_movements(
53
+ p_movements JSONB
54
+ ) RETURNS JSONB
55
+ LANGUAGE plpgsql
56
+ AS $$
57
+ DECLARE
58
+ v_movement JSONB;
59
+ v_ledger_id UUID;
60
+ v_results JSONB := '[]'::JSONB;
61
+ v_result JSONB;
62
+ BEGIN
63
+ -- Process each movement in the array
64
+ FOR v_movement IN SELECT * FROM jsonb_array_elements(p_movements)
65
+ LOOP
66
+ -- Apply individual stock movement
67
+ SELECT apply_stock_movement(
68
+ (v_movement->>'merchant_id')::VARCHAR(64),
69
+ (v_movement->>'location_id')::VARCHAR(64),
70
+ (v_movement->>'catalogue_id')::UUID,
71
+ (v_movement->>'sku')::VARCHAR(64),
72
+ (v_movement->>'batch_no')::VARCHAR(50),
73
+ (v_movement->>'exp_dt')::DATE,
74
+ (v_movement->>'qty')::NUMERIC(14,3),
75
+ (v_movement->>'uom')::VARCHAR(10),
76
+ (v_movement->>'txn_type')::VARCHAR(30),
77
+ (v_movement->>'ref_type')::VARCHAR(30),
78
+ (v_movement->>'ref_id')::UUID,
79
+ (v_movement->>'ref_no')::VARCHAR(50),
80
+ (v_movement->>'remarks')::TEXT,
81
+ (v_movement->>'created_by')::VARCHAR(64)
82
+ ) INTO v_ledger_id;
83
+
84
+ -- Build result object
85
+ v_result := jsonb_build_object(
86
+ 'ledger_id', v_ledger_id,
87
+ 'sku', v_movement->>'sku',
88
+ 'qty', v_movement->>'qty',
89
+ 'success', true
90
+ );
91
+
92
+ -- Add to results array
93
+ v_results := v_results || v_result;
94
+ END LOOP;
95
+
96
+ RETURN v_results;
97
+ END;
98
+ $$;
99
+
100
+ -- Function to reserve stock (for sales orders)
101
+ CREATE OR REPLACE FUNCTION reserve_stock(
102
+ p_merchant_id VARCHAR(64),
103
+ p_location_id VARCHAR(64),
104
+ p_sku VARCHAR(64),
105
+ p_batch_no VARCHAR(50),
106
+ p_qty NUMERIC(14,3),
107
+ p_ref_id UUID,
108
+ p_created_by VARCHAR(64)
109
+ ) RETURNS BOOLEAN
110
+ LANGUAGE plpgsql
111
+ AS $$
112
+ DECLARE
113
+ v_available_qty NUMERIC(14,3);
114
+ BEGIN
115
+ -- Get current available quantity
116
+ SELECT qty_available
117
+ INTO v_available_qty
118
+ FROM scm_stock
119
+ WHERE merchant_id = p_merchant_id
120
+ AND location_id = p_location_id
121
+ AND sku = p_sku
122
+ AND batch_no = p_batch_no;
123
+
124
+ -- Check if sufficient stock available
125
+ IF v_available_qty IS NULL OR v_available_qty < p_qty THEN
126
+ RAISE EXCEPTION 'Insufficient available stock for SKU: %. Available: %, Required: %',
127
+ p_sku, COALESCE(v_available_qty, 0), p_qty;
128
+ END IF;
129
+
130
+ -- Update stock reservation
131
+ UPDATE scm_stock
132
+ SET qty_reserved = qty_reserved + p_qty,
133
+ qty_available = qty_available - p_qty,
134
+ last_updated_at = NOW()
135
+ WHERE merchant_id = p_merchant_id
136
+ AND location_id = p_location_id
137
+ AND sku = p_sku
138
+ AND batch_no = p_batch_no;
139
+
140
+ RETURN TRUE;
141
+ END;
142
+ $$;
143
+
144
+ -- Function to release stock reservation
145
+ CREATE OR REPLACE FUNCTION release_stock_reservation(
146
+ p_merchant_id VARCHAR(64),
147
+ p_location_id VARCHAR(64),
148
+ p_sku VARCHAR(64),
149
+ p_batch_no VARCHAR(50),
150
+ p_qty NUMERIC(14,3),
151
+ p_ref_id UUID,
152
+ p_created_by VARCHAR(64)
153
+ ) RETURNS BOOLEAN
154
+ LANGUAGE plpgsql
155
+ AS $$
156
+ BEGIN
157
+ -- Update stock reservation
158
+ UPDATE scm_stock
159
+ SET qty_reserved = GREATEST(qty_reserved - p_qty, 0),
160
+ qty_available = qty_on_hand - GREATEST(qty_reserved - p_qty, 0),
161
+ last_updated_at = NOW()
162
+ WHERE merchant_id = p_merchant_id
163
+ AND location_id = p_location_id
164
+ AND sku = p_sku
165
+ AND batch_no = p_batch_no;
166
+
167
+ RETURN TRUE;
168
+ END;
169
+ $$;
170
+
171
+ -- Function to get current stock summary
172
+ CREATE OR REPLACE FUNCTION get_stock_summary(
173
+ p_merchant_id VARCHAR(64),
174
+ p_location_id VARCHAR(64) DEFAULT NULL,
175
+ p_sku VARCHAR(64) DEFAULT NULL
176
+ ) RETURNS TABLE (
177
+ stock_id UUID,
178
+ location_id VARCHAR(64),
179
+ catalogue_id UUID,
180
+ sku VARCHAR(64),
181
+ batch_no VARCHAR(50),
182
+ qty_on_hand NUMERIC(14,3),
183
+ qty_reserved NUMERIC(14,3),
184
+ qty_available NUMERIC(14,3),
185
+ uom VARCHAR(10),
186
+ last_updated_at TIMESTAMP
187
+ )
188
+ LANGUAGE plpgsql
189
+ AS $$
190
+ BEGIN
191
+ RETURN QUERY
192
+ SELECT s.stock_id, s.location_id, s.catalogue_id, s.sku, s.batch_no,
193
+ s.qty_on_hand, s.qty_reserved, s.qty_available, s.uom, s.last_updated_at
194
+ FROM scm_stock s
195
+ WHERE s.merchant_id = p_merchant_id
196
+ AND (p_location_id IS NULL OR s.location_id = p_location_id)
197
+ AND (p_sku IS NULL OR s.sku = p_sku)
198
+ ORDER BY s.sku, s.batch_no;
199
+ END;
200
+ $$;
201
+
202
+ -- Function to get stock ledger history
203
+ CREATE OR REPLACE FUNCTION get_stock_ledger(
204
+ p_merchant_id VARCHAR(64),
205
+ p_sku VARCHAR(64) DEFAULT NULL,
206
+ p_batch_no VARCHAR(50) DEFAULT NULL,
207
+ p_limit INTEGER DEFAULT 100
208
+ ) RETURNS TABLE (
209
+ ledger_id UUID,
210
+ location_id VARCHAR(64),
211
+ catalogue_id UUID,
212
+ sku VARCHAR(64),
213
+ batch_no VARCHAR(50),
214
+ txn_type VARCHAR(30),
215
+ qty NUMERIC(14,3),
216
+ uom VARCHAR(10),
217
+ ref_type VARCHAR(30),
218
+ ref_id UUID,
219
+ ref_no VARCHAR(50),
220
+ remarks TEXT,
221
+ created_by VARCHAR(64),
222
+ created_at TIMESTAMP
223
+ )
224
+ LANGUAGE plpgsql
225
+ AS $$
226
+ BEGIN
227
+ RETURN QUERY
228
+ SELECT l.ledger_id, l.location_id, l.catalogue_id, l.sku, l.batch_no,
229
+ l.txn_type, l.qty, l.uom, l.ref_type, l.ref_id, l.ref_no,
230
+ l.remarks, l.created_by, l.created_at
231
+ FROM scm_stock_ledger l
232
+ WHERE l.merchant_id = p_merchant_id
233
+ AND (p_sku IS NULL OR l.sku = p_sku)
234
+ AND (p_batch_no IS NULL OR l.batch_no = p_batch_no)
235
+ ORDER BY l.created_at DESC
236
+ LIMIT p_limit;
237
+ END;
238
+ $$;