MukeshKapoor25 commited on
Commit
91c6b38
·
1 Parent(s): 9198eea

feat(catalogues): enhance UOM validation with dedicated validator service

Browse files

- Replace generic unit field with uom_group_id in Inventory schema for UUID-based validation
- Add uom_group_code format validation using regex pattern (UOM-XXX-NNNNNN)
- Create new UOMValidator service class for centralized UOM group validation
- Implement _validate_uom_group method in CatalogueService for create and update operations
- Add UOM group validation checks during catalogue creation and updates
- Improve error handling and logging for UOM validation failures
- Ensure UOM consistency across catalogue, inventory, and purchase modules

app/catalogues/schemas/schema.py CHANGED
@@ -428,7 +428,7 @@ class InventoryLevel(BaseModel):
428
 
429
 
430
  class Inventory(BaseModel):
431
- unit: str = Field("PCS", description="Unit of measurement (validated against UOM master)")
432
  uom_group_code: Optional[str] = Field(None, description="UOM group code (e.g., UOM-VOL-000001, UOM-WGT-000001)")
433
  levels: Optional[Dict[str, InventoryLevel]] = Field(
434
  None,
@@ -454,14 +454,17 @@ class Inventory(BaseModel):
454
  description="Legacy: Warehouse location code"
455
  )
456
 
457
- @field_validator("unit")
458
- def validate_unit(cls, v):
459
- """Normalize unit code - actual validation against UOM master happens at service level"""
460
  if v:
461
- v = v.strip().upper()
462
  if not v:
463
- return "PCS" # Default fallback
464
- return v or "PCS"
 
 
 
465
 
466
  @field_validator("uom_group_code")
467
  def validate_uom_group_code(cls, v):
@@ -470,6 +473,9 @@ class Inventory(BaseModel):
470
  v = v.strip().upper()
471
  if not v:
472
  return None
 
 
 
473
  return v
474
 
475
  @field_validator("levels")
 
428
 
429
 
430
  class Inventory(BaseModel):
431
+ uom_group_id: Optional[str] = Field(None, description="UOM group UUID")
432
  uom_group_code: Optional[str] = Field(None, description="UOM group code (e.g., UOM-VOL-000001, UOM-WGT-000001)")
433
  levels: Optional[Dict[str, InventoryLevel]] = Field(
434
  None,
 
454
  description="Legacy: Warehouse location code"
455
  )
456
 
457
+ @field_validator("uom_group_id")
458
+ def validate_uom_group_id(cls, v):
459
+ """Validate UOM group ID format (UUID)"""
460
  if v:
461
+ v = v.strip()
462
  if not v:
463
+ return None
464
+ # Basic UUID format validation (will be validated against UOM master at service level)
465
+ if len(v) < 10:
466
+ raise ValueError("UOM group ID must be a valid UUID")
467
+ return v
468
 
469
  @field_validator("uom_group_code")
470
  def validate_uom_group_code(cls, v):
 
473
  v = v.strip().upper()
474
  if not v:
475
  return None
476
+ # Basic format validation (UOM-XXX-NNNNNN)
477
+ if not re.match(r"^UOM-[A-Z]{3}-\d{6}$", v):
478
+ logger.warning(f"UOM group code '{v}' doesn't match expected format UOM-XXX-NNNNNN")
479
  return v
480
 
481
  @field_validator("levels")
app/catalogues/services/service.py CHANGED
@@ -99,6 +99,23 @@ class CatalogueService:
99
 
100
  return len(valid_categories) == 0 # Allow if no categories defined in taxonomy
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  async def get_brand_suggestions(self, partial: str, merchant_id: str = None) -> List[str]:
103
  """
104
  Get brand suggestions for partial input from taxonomy data.
@@ -300,6 +317,11 @@ class CatalogueService:
300
  detail=str(e)
301
  )
302
 
 
 
 
 
 
303
  # Validate inventory levels against merchant's allowed levels
304
  if data.inventory and data.inventory.levels and merchant_id:
305
  from app.catalogues.utils import validate_inventory_levels_for_merchant
@@ -399,6 +421,15 @@ class CatalogueService:
399
  detail=str(e)
400
  )
401
 
 
 
 
 
 
 
 
 
 
402
  # Validate inventory levels if being updated
403
  if "inventory" in update_data and update_data["inventory"] and merchant_id:
404
  inventory_data = update_data["inventory"]
 
99
 
100
  return len(valid_categories) == 0 # Allow if no categories defined in taxonomy
101
 
102
+ async def _validate_uom_group(self, uom_group_id: str = None, uom_group_code: str = None) -> bool:
103
+ """
104
+ Validate UOM group against UOM master data.
105
+ """
106
+ if not uom_group_id and not uom_group_code:
107
+ return True # No UOM specified is valid
108
+
109
+ try:
110
+ from app.catalogues.uom_validator import UOMValidator
111
+ validator = UOMValidator(self.db)
112
+ await validator.validate_uom_group(uom_group_id, uom_group_code)
113
+ return True
114
+
115
+ except Exception as e:
116
+ # Re-raise the exception from the validator
117
+ raise e
118
+
119
  async def get_brand_suggestions(self, partial: str, merchant_id: str = None) -> List[str]:
120
  """
121
  Get brand suggestions for partial input from taxonomy data.
 
317
  detail=str(e)
318
  )
319
 
320
+ # Validate UOM group if provided
321
+ if data.inventory and (data.inventory.uom_group_id or data.inventory.uom_group_code):
322
+ await self._validate_uom_group(data.inventory.uom_group_id, data.inventory.uom_group_code)
323
+ )
324
+
325
  # Validate inventory levels against merchant's allowed levels
326
  if data.inventory and data.inventory.levels and merchant_id:
327
  from app.catalogues.utils import validate_inventory_levels_for_merchant
 
421
  detail=str(e)
422
  )
423
 
424
+ # Validate UOM group if being updated
425
+ if "inventory" in update_data and update_data["inventory"]:
426
+ inventory_data = update_data["inventory"]
427
+ if isinstance(inventory_data, dict):
428
+ uom_group_id = inventory_data.get("uom_group_id")
429
+ uom_group_code = inventory_data.get("uom_group_code")
430
+ if uom_group_id or uom_group_code:
431
+ await self._validate_uom_group(uom_group_id, uom_group_code)
432
+
433
  # Validate inventory levels if being updated
434
  if "inventory" in update_data and update_data["inventory"] and merchant_id:
435
  inventory_data = update_data["inventory"]
app/catalogues/uom_validator.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UOM Validation Utility for Catalogue Integration
3
+ Validates UOM consistency across catalogue, inventory, and purchase operations.
4
+ """
5
+
6
+ import logging
7
+ from typing import Optional, Dict, Any, List
8
+ from fastapi import HTTPException, status
9
+ from motor.core import AgnosticDatabase as AsyncIOMotorDatabase
10
+
11
+ from app.uom.constants import SCM_UOM_GROUP_COLLECTION
12
+ from app.catalogues.constants import SCM_CATALOGUE_COLLECTION
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class UOMValidator:
18
+ """
19
+ Centralized UOM validation service for catalogue operations.
20
+ Ensures UOM consistency across catalogue, inventory, and purchase modules.
21
+ """
22
+
23
+ def __init__(self, db: AsyncIOMotorDatabase):
24
+ self.db = db
25
+ self.uom_collection = db[SCM_UOM_GROUP_COLLECTION]
26
+ self.catalogue_collection = db[SCM_CATALOGUE_COLLECTION]
27
+
28
+ async def validate_uom_group(self, uom_group_id: str = None, uom_group_code: str = None) -> Dict[str, Any]:
29
+ """
30
+ Validate UOM group exists and is active.
31
+
32
+ Args:
33
+ uom_group_id: UOM group UUID
34
+ uom_group_code: UOM group code (e.g., UOM-VOL-000001)
35
+
36
+ Returns:
37
+ UOM group document if valid
38
+
39
+ Raises:
40
+ HTTPException: If UOM group is invalid or not found
41
+ """
42
+ if not uom_group_id and not uom_group_code:
43
+ raise HTTPException(
44
+ status_code=status.HTTP_400_BAD_REQUEST,
45
+ detail="Either uom_group_id or uom_group_code must be provided"
46
+ )
47
+
48
+ try:
49
+ # Build query based on available identifiers
50
+ query = {"status": "active"}
51
+ if uom_group_id:
52
+ query["uom_group_id"] = uom_group_id
53
+ elif uom_group_code:
54
+ query["uom_group_code"] = uom_group_code
55
+
56
+ # Check if UOM group exists and is active
57
+ uom_group = await self.uom_collection.find_one(query)
58
+
59
+ if not uom_group:
60
+ error_msg = "UOM group not found or inactive"
61
+ if uom_group_id:
62
+ error_msg += f" (ID: {uom_group_id})"
63
+ if uom_group_code:
64
+ error_msg += f" (Code: {uom_group_code})"
65
+
66
+ raise HTTPException(
67
+ status_code=status.HTTP_400_BAD_REQUEST,
68
+ detail=error_msg
69
+ )
70
+
71
+ logger.debug(f"UOM group validated: {uom_group.get('name')} ({uom_group.get('uom_group_code')})")
72
+ return uom_group
73
+
74
+ except HTTPException:
75
+ raise
76
+ except Exception as e:
77
+ logger.error(f"Error validating UOM group: {e}")
78
+ raise HTTPException(
79
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
80
+ detail="Error validating UOM group"
81
+ )
82
+
83
+ async def validate_unit_in_group(self, unit_code: str, uom_group_id: str = None, uom_group_code: str = None) -> bool:
84
+ """
85
+ Validate that a unit code exists in the specified UOM group.
86
+
87
+ Args:
88
+ unit_code: Unit code to validate (e.g., 'ml', 'g', 'pcs')
89
+ uom_group_id: UOM group UUID
90
+ uom_group_code: UOM group code
91
+
92
+ Returns:
93
+ True if unit is valid in the group
94
+
95
+ Raises:
96
+ HTTPException: If validation fails
97
+ """
98
+ if not unit_code:
99
+ raise HTTPException(
100
+ status_code=status.HTTP_400_BAD_REQUEST,
101
+ detail="Unit code is required"
102
+ )
103
+
104
+ # First validate the UOM group
105
+ uom_group = await self.validate_uom_group(uom_group_id, uom_group_code)
106
+
107
+ # Check if unit exists in the group
108
+ units = uom_group.get("units", [])
109
+ unit_codes = [unit.get("code", "").lower() for unit in units if unit.get("status") == "active"]
110
+
111
+ if unit_code.lower() not in unit_codes:
112
+ raise HTTPException(
113
+ status_code=status.HTTP_400_BAD_REQUEST,
114
+ detail=f"Unit '{unit_code}' is not valid for UOM group '{uom_group.get('name')}'. Valid units: {', '.join(unit_codes)}"
115
+ )
116
+
117
+ logger.debug(f"Unit '{unit_code}' validated in UOM group '{uom_group.get('name')}'")
118
+ return True
119
+
120
+ async def validate_catalogue_uom_consistency(self, catalogue_id: str, unit_code: str) -> bool:
121
+ """
122
+ Validate that a unit code is consistent with the catalogue's UOM group.
123
+
124
+ Args:
125
+ catalogue_id: Catalogue UUID
126
+ unit_code: Unit code to validate
127
+
128
+ Returns:
129
+ True if unit is consistent with catalogue's UOM group
130
+
131
+ Raises:
132
+ HTTPException: If validation fails
133
+ """
134
+ if not catalogue_id or not unit_code:
135
+ raise HTTPException(
136
+ status_code=status.HTTP_400_BAD_REQUEST,
137
+ detail="Both catalogue_id and unit_code are required"
138
+ )
139
+
140
+ try:
141
+ # Get catalogue document
142
+ catalogue = await self.catalogue_collection.find_one({"catalogue_id": catalogue_id})
143
+
144
+ if not catalogue:
145
+ raise HTTPException(
146
+ status_code=status.HTTP_404_NOT_FOUND,
147
+ detail=f"Catalogue not found: {catalogue_id}"
148
+ )
149
+
150
+ # Extract UOM group information from catalogue
151
+ uom_group_id = None
152
+ uom_group_code = None
153
+
154
+ # Check inventory section first
155
+ if "inventory" in catalogue and catalogue["inventory"]:
156
+ inventory = catalogue["inventory"]
157
+ uom_group_id = inventory.get("uom_group_id")
158
+ uom_group_code = inventory.get("uom_group_code")
159
+
160
+ # Fallback to root level
161
+ if not uom_group_id and not uom_group_code:
162
+ uom_group_id = catalogue.get("uom_group_id")
163
+ uom_group_code = catalogue.get("uom_group_code")
164
+
165
+ # If no UOM group is specified, allow any unit (backward compatibility)
166
+ if not uom_group_id and not uom_group_code:
167
+ logger.warning(f"Catalogue {catalogue_id} has no UOM group specified, allowing unit '{unit_code}'")
168
+ return True
169
+
170
+ # Validate unit against the catalogue's UOM group
171
+ return await self.validate_unit_in_group(unit_code, uom_group_id, uom_group_code)
172
+
173
+ except HTTPException:
174
+ raise
175
+ except Exception as e:
176
+ logger.error(f"Error validating catalogue UOM consistency: {e}")
177
+ raise HTTPException(
178
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
179
+ detail="Error validating catalogue UOM consistency"
180
+ )
181
+
182
+ async def get_valid_units_for_catalogue(self, catalogue_id: str) -> List[Dict[str, Any]]:
183
+ """
184
+ Get list of valid units for a catalogue based on its UOM group.
185
+
186
+ Args:
187
+ catalogue_id: Catalogue UUID
188
+
189
+ Returns:
190
+ List of valid unit dictionaries with code, name, and conversion info
191
+
192
+ Raises:
193
+ HTTPException: If catalogue not found
194
+ """
195
+ try:
196
+ # Get catalogue document
197
+ catalogue = await self.catalogue_collection.find_one({"catalogue_id": catalogue_id})
198
+
199
+ if not catalogue:
200
+ raise HTTPException(
201
+ status_code=status.HTTP_404_NOT_FOUND,
202
+ detail=f"Catalogue not found: {catalogue_id}"
203
+ )
204
+
205
+ # Extract UOM group information
206
+ uom_group_id = None
207
+ uom_group_code = None
208
+
209
+ if "inventory" in catalogue and catalogue["inventory"]:
210
+ inventory = catalogue["inventory"]
211
+ uom_group_id = inventory.get("uom_group_id")
212
+ uom_group_code = inventory.get("uom_group_code")
213
+
214
+ if not uom_group_id and not uom_group_code:
215
+ uom_group_id = catalogue.get("uom_group_id")
216
+ uom_group_code = catalogue.get("uom_group_code")
217
+
218
+ # If no UOM group specified, return empty list
219
+ if not uom_group_id and not uom_group_code:
220
+ return []
221
+
222
+ # Get UOM group and return active units
223
+ uom_group = await self.validate_uom_group(uom_group_id, uom_group_code)
224
+ units = uom_group.get("units", [])
225
+
226
+ # Filter active units and return relevant information
227
+ valid_units = []
228
+ for unit in units:
229
+ if unit.get("status") == "active":
230
+ valid_units.append({
231
+ "code": unit.get("code"),
232
+ "name": unit.get("name"),
233
+ "conversion_to_base": unit.get("conversion_to_base"),
234
+ "is_base": unit.get("is_base", False)
235
+ })
236
+
237
+ return valid_units
238
+
239
+ except HTTPException:
240
+ raise
241
+ except Exception as e:
242
+ logger.error(f"Error getting valid units for catalogue: {e}")
243
+ raise HTTPException(
244
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
245
+ detail="Error getting valid units for catalogue"
246
+ )
247
+
248
+ async def convert_quantity(self, from_unit: str, to_unit: str, quantity: float, uom_group_id: str = None, uom_group_code: str = None) -> float:
249
+ """
250
+ Convert quantity from one unit to another within the same UOM group.
251
+
252
+ Args:
253
+ from_unit: Source unit code
254
+ to_unit: Target unit code
255
+ quantity: Quantity to convert
256
+ uom_group_id: UOM group UUID
257
+ uom_group_code: UOM group code
258
+
259
+ Returns:
260
+ Converted quantity
261
+
262
+ Raises:
263
+ HTTPException: If conversion fails
264
+ """
265
+ if from_unit.lower() == to_unit.lower():
266
+ return quantity
267
+
268
+ try:
269
+ # Get UOM group
270
+ uom_group = await self.validate_uom_group(uom_group_id, uom_group_code)
271
+ units = uom_group.get("units", [])
272
+
273
+ # Find conversion factors
274
+ from_conversion = None
275
+ to_conversion = None
276
+
277
+ for unit in units:
278
+ if unit.get("code", "").lower() == from_unit.lower() and unit.get("status") == "active":
279
+ from_conversion = unit.get("conversion_to_base")
280
+ elif unit.get("code", "").lower() == to_unit.lower() and unit.get("status") == "active":
281
+ to_conversion = unit.get("conversion_to_base")
282
+
283
+ if from_conversion is None:
284
+ raise HTTPException(
285
+ status_code=status.HTTP_400_BAD_REQUEST,
286
+ detail=f"Source unit '{from_unit}' not found in UOM group"
287
+ )
288
+
289
+ if to_conversion is None:
290
+ raise HTTPException(
291
+ status_code=status.HTTP_400_BAD_REQUEST,
292
+ detail=f"Target unit '{to_unit}' not found in UOM group"
293
+ )
294
+
295
+ # Convert: quantity * from_conversion / to_conversion
296
+ converted_quantity = (quantity * from_conversion) / to_conversion
297
+
298
+ logger.debug(f"Converted {quantity} {from_unit} to {converted_quantity} {to_unit}")
299
+ return converted_quantity
300
+
301
+ except HTTPException:
302
+ raise
303
+ except Exception as e:
304
+ logger.error(f"Error converting quantity: {e}")
305
+ raise HTTPException(
306
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
307
+ detail="Error converting quantity"
308
+ )
309
+
310
+
311
+ # Convenience functions for easy import
312
+ async def validate_catalogue_uom(db: AsyncIOMotorDatabase, catalogue_id: str, unit_code: str) -> bool:
313
+ """
314
+ Convenience function to validate UOM consistency for a catalogue.
315
+
316
+ Args:
317
+ db: MongoDB database instance
318
+ catalogue_id: Catalogue UUID
319
+ unit_code: Unit code to validate
320
+
321
+ Returns:
322
+ True if valid
323
+ """
324
+ validator = UOMValidator(db)
325
+ return await validator.validate_catalogue_uom_consistency(catalogue_id, unit_code)
326
+
327
+
328
+ async def get_catalogue_valid_units(db: AsyncIOMotorDatabase, catalogue_id: str) -> List[Dict[str, Any]]:
329
+ """
330
+ Convenience function to get valid units for a catalogue.
331
+
332
+ Args:
333
+ db: MongoDB database instance
334
+ catalogue_id: Catalogue UUID
335
+
336
+ Returns:
337
+ List of valid units
338
+ """
339
+ validator = UOMValidator(db)
340
+ return await validator.get_valid_units_for_catalogue(catalogue_id)
app/sync/catalogues/handler.py CHANGED
@@ -371,8 +371,20 @@ class CatalogueSyncHandler(SyncHandler):
371
  if "inventory" in entity and entity["inventory"]:
372
  inventory = entity["inventory"]
373
  flattened["track_inventory"] = inventory.get("track_inventory", False)
 
 
 
 
374
  else:
375
  flattened["track_inventory"] = False
 
 
 
 
 
 
 
 
376
 
377
  # Extract from procurement (batch_managed)
378
  if "procurement" in entity and entity["procurement"]:
@@ -512,13 +524,15 @@ class CatalogueSyncHandler(SyncHandler):
512
  UNION SELECT 'category'
513
  UNION SELECT 'pricing'
514
  UNION SELECT 'updated_at'
 
 
515
  ) expected_cols
516
  WHERE column_name NOT IN (
517
  SELECT column_name
518
  FROM information_schema.columns
519
  WHERE table_schema = 'trans'
520
  AND table_name = 'catalogue_ref'
521
- AND column_name IN ('catalogue_code', 'brand', 'category', 'pricing', 'updated_at')
522
  )
523
  """)
524
 
 
371
  if "inventory" in entity and entity["inventory"]:
372
  inventory = entity["inventory"]
373
  flattened["track_inventory"] = inventory.get("track_inventory", False)
374
+
375
+ # Extract UOM information from inventory
376
+ flattened["uom_group_id"] = inventory.get("uom_group_id")
377
+ flattened["uom_group_code"] = inventory.get("uom_group_code")
378
  else:
379
  flattened["track_inventory"] = False
380
+ flattened["uom_group_id"] = None
381
+ flattened["uom_group_code"] = None
382
+
383
+ # Also check for UOM fields at root level (fallback)
384
+ if flattened.get("uom_group_id") is None:
385
+ flattened["uom_group_id"] = entity.get("uom_group_id")
386
+ if flattened.get("uom_group_code") is None:
387
+ flattened["uom_group_code"] = entity.get("uom_group_code")
388
 
389
  # Extract from procurement (batch_managed)
390
  if "procurement" in entity and entity["procurement"]:
 
524
  UNION SELECT 'category'
525
  UNION SELECT 'pricing'
526
  UNION SELECT 'updated_at'
527
+ UNION SELECT 'uom_group_id'
528
+ UNION SELECT 'uom_group_code'
529
  ) expected_cols
530
  WHERE column_name NOT IN (
531
  SELECT column_name
532
  FROM information_schema.columns
533
  WHERE table_schema = 'trans'
534
  AND table_name = 'catalogue_ref'
535
+ AND column_name IN ('catalogue_code', 'brand', 'category', 'pricing', 'updated_at', 'uom_group_id', 'uom_group_code')
536
  )
537
  """)
538
 
app/sync/catalogues/models.py CHANGED
@@ -18,6 +18,7 @@ CATALOGUE_FIELD_MAPPING = {
18
  "base_price": "base_price", # NUMERIC(12,2)
19
  "track_inventory": "track_inventory", # BOOLEAN
20
  "batch_managed": "batch_managed", # BOOLEAN
 
21
  "uom_group_code": "uom_group_code", # TEXT
22
  "status": "status", # TEXT NOT NULL
23
  "created_at": "created_at", # TIMESTAMP NOT NULL
 
18
  "base_price": "base_price", # NUMERIC(12,2)
19
  "track_inventory": "track_inventory", # BOOLEAN
20
  "batch_managed": "batch_managed", # BOOLEAN
21
+ "uom_group_id": "uom_group_id", # UUID
22
  "uom_group_code": "uom_group_code", # TEXT
23
  "status": "status", # TEXT NOT NULL
24
  "created_at": "created_at", # TIMESTAMP NOT NULL