joycecast commited on
Commit
36fa38a
·
verified ·
1 Parent(s): 56c462f

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +43 -1
  2. hts_validator.py +897 -535
app.py CHANGED
@@ -180,6 +180,11 @@ def format_hts(hts_value):
180
  return s
181
 
182
 
 
 
 
 
 
183
  def results_to_dataframe(results):
184
  """Convert validation results to DataFrame"""
185
  data = []
@@ -196,6 +201,19 @@ def results_to_dataframe(results):
196
  "Full Description": r.description,
197
  "Primary HTS": format_hts(r.primary_hts),
198
  "Additional HTS": additional_hts_str,
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  "Scenario": r.scenario_id,
200
  "Scenario Summary": r.scenario_summary,
201
  "Status": r.status,
@@ -434,7 +452,13 @@ with tab2:
434
  # Select columns to display
435
  display_columns = [
436
  "Entry Number", "Description", "Primary HTS",
437
- "Additional HTS", "Scenario", "Status", "Issue"
 
 
 
 
 
 
438
  ]
439
 
440
  # Interactive filtering section
@@ -621,6 +645,19 @@ with tab2b:
621
  ).agg({
622
  "Entry Number": "count", # Count occurrences
623
  "Additional HTS": "first", # Take first (should be same for same HTS+desc)
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  "Scenario": "first",
625
  "Scenario Summary": "first",
626
  "Status": "first",
@@ -682,6 +719,11 @@ with tab2b:
682
  # Display columns
683
  display_cols = [
684
  "Primary HTS", "Description", "Additional HTS",
 
 
 
 
 
685
  "Scenario", "Status", "Count", "Issue"
686
  ]
687
 
 
180
  return s
181
 
182
 
183
+ def bool_to_symbol(value: bool) -> str:
184
+ """Convert boolean to check/cross symbol"""
185
+ return "Y" if value else "N"
186
+
187
+
188
  def results_to_dataframe(results):
189
  """Convert validation results to DataFrame"""
190
  data = []
 
201
  "Full Description": r.description,
202
  "Primary HTS": format_hts(r.primary_hts),
203
  "Additional HTS": additional_hts_str,
204
+ # HTS membership indicators
205
+ "Steel HTS": bool_to_symbol(r.in_steel_hts),
206
+ "Alum HTS": bool_to_symbol(r.in_aluminum_hts),
207
+ "Copper HTS": bool_to_symbol(r.in_copper_hts),
208
+ "Computer HTS": bool_to_symbol(r.in_computer_hts),
209
+ "Auto HTS": bool_to_symbol(r.in_auto_hts),
210
+ # Keyword indicators
211
+ "Metal KW": bool_to_symbol(r.has_metal_keyword),
212
+ "Alum KW": bool_to_symbol(r.has_aluminum_keyword),
213
+ "Copper KW": bool_to_symbol(r.has_copper_keyword),
214
+ "Zinc KW": bool_to_symbol(r.has_zinc_keyword),
215
+ "Plastics KW": bool_to_symbol(r.has_plastics_keyword),
216
+ # Validation results
217
  "Scenario": r.scenario_id,
218
  "Scenario Summary": r.scenario_summary,
219
  "Status": r.status,
 
452
  # Select columns to display
453
  display_columns = [
454
  "Entry Number", "Description", "Primary HTS",
455
+ "Additional HTS",
456
+ # HTS indicators
457
+ "Steel HTS", "Alum HTS", "Copper HTS", "Computer HTS", "Auto HTS",
458
+ # Keyword indicators
459
+ "Metal KW", "Alum KW", "Copper KW", "Zinc KW", "Plastics KW",
460
+ # Validation
461
+ "Scenario", "Status", "Issue"
462
  ]
463
 
464
  # Interactive filtering section
 
645
  ).agg({
646
  "Entry Number": "count", # Count occurrences
647
  "Additional HTS": "first", # Take first (should be same for same HTS+desc)
648
+ # HTS indicators
649
+ "Steel HTS": "first",
650
+ "Alum HTS": "first",
651
+ "Copper HTS": "first",
652
+ "Computer HTS": "first",
653
+ "Auto HTS": "first",
654
+ # Keyword indicators
655
+ "Metal KW": "first",
656
+ "Alum KW": "first",
657
+ "Copper KW": "first",
658
+ "Zinc KW": "first",
659
+ "Plastics KW": "first",
660
+ # Validation
661
  "Scenario": "first",
662
  "Scenario Summary": "first",
663
  "Status": "first",
 
719
  # Display columns
720
  display_cols = [
721
  "Primary HTS", "Description", "Additional HTS",
722
+ # HTS indicators
723
+ "Steel HTS", "Alum HTS", "Copper HTS", "Computer HTS", "Auto HTS",
724
+ # Keyword indicators
725
+ "Metal KW", "Alum KW", "Copper KW", "Zinc KW", "Plastics KW",
726
+ # Validation
727
  "Scenario", "Status", "Count", "Issue"
728
  ]
729
 
hts_validator.py CHANGED
@@ -1,6 +1,15 @@
1
  """
2
  HTS Validator - Core validation logic for HTS tariff auditing
3
  Validates primary HTS codes against additional HTS and description keywords
 
 
 
 
 
 
 
 
 
4
  """
5
 
6
  import re
@@ -17,26 +26,86 @@ COPPER_CODES = {"99037801", "99037802"}
17
  GENERAL_301_CODE = "99030133"
18
  MISMATCH_CODE = "99030125"
19
 
20
- # Scenario summaries
 
 
 
21
  SCENARIO_SUMMARIES = {
22
- "S1": "Steel HTS + 232 tariff applied - verify 99030133 present, no 99030125",
23
- "S2": "Metal keyword but NOT steel HTS - should apply 99030125, no 232 tariffs",
24
- "S4": "Aluminum HTS + 232 tariff applied - verify 99030133 present, no 99030125",
25
- "S5": "Aluminum keyword but NOT aluminum HTS - should apply 99030125, no 232 tariffs",
26
- "S7": "Dual Steel+Aluminum HTS - matches keyword, apply corresponding 232, no 99030125",
27
- "S7a": "Dual Steel+Aluminum HTS + BOTH keywords - flag for manual review",
28
- "S8": "Dual Steel+Aluminum HTS + NO keywords - flag for manual review",
29
- "S9": "Copper keyword but NOT copper HTS - should apply 99030125, no 232 tariffs",
30
- "S11": "Dual Aluminum+Copper HTS + copper keyword - apply 99030133 + copper tariffs, no 99030125",
31
- "S12": "Dual Aluminum+Copper HTS + aluminum keyword - apply 99030133 + aluminum 232, no 99030125",
32
- "S13": "Zinc keyword - should ONLY apply 99030125, no 232 tariffs allowed",
33
- "S14": "Plastics keyword + metal HTS - override, should ONLY apply 99030125",
34
- "S15": "Steel HTS + aluminum keyword - should apply 99030125, no 99030133 or 232 tariffs",
35
- "S16": "Aluminum HTS + steel keyword - should apply 99030125, no 99030133 or 232 tariffs",
36
- "S17": "Copper HTS but NO copper keyword - should apply copper tariffs + 99030133, no 99030125",
37
- "COPPER_OK": "Copper HTS + copper keyword - verify copper tariffs applied",
38
- "S18": "Computer Parts HTS - flag for manual review",
39
- "S19": "Auto Parts HTS - flag for manual review",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  "NONE": "No applicable scenario - entry does not match any validation rules",
41
  }
42
 
@@ -50,11 +119,23 @@ class ValidationResult:
50
  additional_hts: List[str]
51
  scenario_id: str
52
  scenario_summary: str
53
- status: str # PASS, FAIL, FLAG
54
  expected_hts: List[str]
55
  missing_hts: List[str]
56
  unexpected_hts: List[str]
57
  issue: str
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
 
60
  class HTSValidator:
@@ -151,6 +232,25 @@ class HTSValidator:
151
  """Check if any of the HTS codes are present"""
152
  return bool(hts_codes & additional_set)
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  def validate_entry(self, entry_number: str, description: str,
155
  primary_hts: str, additional_hts: List[str]) -> ValidationResult:
156
  """Validate a single entry against all scenarios"""
@@ -181,7 +281,10 @@ class HTSValidator:
181
  has_301 = self._check_hts_present(GENERAL_301_CODE, additional_set)
182
  has_mismatch = self._check_hts_present(MISMATCH_CODE, additional_set)
183
 
184
- # Apply validation rules in priority order
 
 
 
185
  return self._apply_validation_rules(
186
  entry_number=entry_number,
187
  description=desc,
@@ -202,7 +305,123 @@ class HTSValidator:
202
  has_copper_tariff=has_copper_tariff,
203
  has_301=has_301,
204
  has_mismatch=has_mismatch,
205
- additional_set=additional_set
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  )
207
 
208
  def _apply_validation_rules(self, entry_number: str, description: str,
@@ -214,527 +433,675 @@ class HTSValidator:
214
  has_plastics_kw: bool, has_steel_232: bool,
215
  has_aluminum_232: bool, has_copper_tariff: bool,
216
  has_301: bool, has_mismatch: bool,
217
- additional_set: Set[str]) -> ValidationResult:
218
- """Apply all validation rules and return result"""
219
-
220
- # Priority 1: Special overrides (zinc, plastics)
221
-
222
- # S13: Zinc keyword - only 99030125, no 232 tariffs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  if has_zinc_kw:
224
- expected = [MISMATCH_CODE]
225
- issues = []
226
-
227
- if not has_mismatch:
228
- issues.append("Missing 99030125")
229
- if has_steel_232 or has_aluminum_232 or has_copper_tariff:
230
- issues.append("Should NOT have 232/copper tariffs with zinc")
231
-
232
- status = "PASS" if not issues else "FAIL"
233
- return ValidationResult(
234
- entry_number=entry_number,
235
- description=description,
236
- primary_hts=primary_hts,
237
- additional_hts=additional_hts,
238
- scenario_id="S13",
239
- scenario_summary=SCENARIO_SUMMARIES["S13"],
240
- status=status,
241
- expected_hts=expected,
242
- missing_hts=[MISMATCH_CODE] if not has_mismatch else [],
243
- unexpected_hts=self._get_unexpected_232(additional_set),
244
- issue="; ".join(issues) if issues else "Correct - zinc with only 99030125"
245
  )
246
 
247
- # S14: Plastics keyword + metal HTS - only 99030125
248
- if has_plastics_kw and (in_steel or in_aluminum):
249
- expected = [MISMATCH_CODE]
250
- issues = []
251
-
252
- if not has_mismatch:
253
- issues.append("Missing 99030125")
254
- if has_steel_232 or has_aluminum_232:
255
- issues.append("Should NOT have 232 tariffs with plastics material")
256
-
257
- status = "PASS" if not issues else "FAIL"
258
- return ValidationResult(
259
- entry_number=entry_number,
260
- description=description,
261
- primary_hts=primary_hts,
262
- additional_hts=additional_hts,
263
- scenario_id="S14",
264
- scenario_summary=SCENARIO_SUMMARIES["S14"],
265
- status=status,
266
- expected_hts=expected,
267
- missing_hts=[MISMATCH_CODE] if not has_mismatch else [],
268
- unexpected_hts=self._get_unexpected_232(additional_set),
269
- issue="; ".join(issues) if issues else "Correct - plastics with only 99030125"
270
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
- # Priority 2: Computer Parts and Auto Parts - FLAG for review
 
 
273
 
274
- # S18: Computer Parts HTS - flag for manual review
275
  if in_computer_parts:
276
- return ValidationResult(
277
- entry_number=entry_number,
278
- description=description,
279
- primary_hts=primary_hts,
280
- additional_hts=additional_hts,
281
- scenario_id="S18",
282
- scenario_summary=SCENARIO_SUMMARIES["S18"],
283
- status="FLAG",
284
- expected_hts=[],
285
- missing_hts=[],
286
- unexpected_hts=[],
287
- issue="Computer parts HTS - manual review required for tariff verification"
288
  )
289
 
290
- # S19: Auto Parts HTS - flag for manual review
291
  if in_auto_parts:
292
- return ValidationResult(
293
- entry_number=entry_number,
294
- description=description,
295
- primary_hts=primary_hts,
296
- additional_hts=additional_hts,
297
- scenario_id="S19",
298
- scenario_summary=SCENARIO_SUMMARIES["S19"],
299
- status="FLAG",
300
- expected_hts=[],
301
- missing_hts=[],
302
- unexpected_hts=[],
303
- issue="Auto parts HTS - manual review required for tariff verification"
304
  )
305
 
306
- # Priority 3: Dual list scenarios
307
-
308
- # Dual Aluminum + Copper
309
- if in_aluminum and in_copper:
310
- # S11: Copper keyword
311
- if has_copper_kw:
312
- expected = [GENERAL_301_CODE] + list(COPPER_CODES)
313
- issues = []
314
-
315
- if not has_301:
316
- issues.append("Missing 99030133")
317
- if not has_copper_tariff:
318
- issues.append("Missing copper tariff (99037801/02)")
319
- if has_aluminum_232:
320
- issues.append("Should NOT have aluminum 232 when description says copper")
321
- if has_mismatch:
322
- issues.append("Should NOT have 99030125 with correct copper classification")
323
-
324
- status = "PASS" if not issues else "FAIL"
325
- return ValidationResult(
326
- entry_number=entry_number,
327
- description=description,
328
- primary_hts=primary_hts,
329
- additional_hts=additional_hts,
330
- scenario_id="S11",
331
- scenario_summary=SCENARIO_SUMMARIES["S11"],
332
- status=status,
333
- expected_hts=expected,
334
- missing_hts=self._get_missing_codes([GENERAL_301_CODE], has_301, COPPER_CODES, has_copper_tariff),
335
- unexpected_hts=list(ALUMINUM_232_CODES & additional_set) + ([MISMATCH_CODE] if has_mismatch else []),
336
- issue="; ".join(issues) if issues else "Correct - dual AL/CU with copper keyword"
337
- )
338
-
339
- # S12: Aluminum keyword
340
- if has_aluminum_kw:
341
- expected = [GENERAL_301_CODE] + list(ALUMINUM_232_CODES)
342
- issues = []
343
-
344
- if not has_301:
345
- issues.append("Missing 99030133")
346
- if not has_aluminum_232:
347
- issues.append("Missing aluminum 232 tariff (99038507/08)")
348
- if has_copper_tariff:
349
- issues.append("Should NOT have copper tariff when description says aluminum")
350
- if has_mismatch:
351
- issues.append("Should NOT have 99030125 with correct aluminum classification")
352
-
353
- status = "PASS" if not issues else "FAIL"
354
- return ValidationResult(
355
- entry_number=entry_number,
356
- description=description,
357
- primary_hts=primary_hts,
358
- additional_hts=additional_hts,
359
- scenario_id="S12",
360
- scenario_summary=SCENARIO_SUMMARIES["S12"],
361
- status=status,
362
- expected_hts=expected,
363
- missing_hts=self._get_missing_codes([GENERAL_301_CODE], has_301, ALUMINUM_232_CODES, has_aluminum_232),
364
- unexpected_hts=list(COPPER_CODES & additional_set) + ([MISMATCH_CODE] if has_mismatch else []),
365
- issue="; ".join(issues) if issues else "Correct - dual AL/CU with aluminum keyword"
366
- )
367
-
368
- # Dual Steel + Aluminum
369
- if in_steel and in_aluminum:
370
- # S7a: Both keywords - flag for review
371
- if has_metal_kw and has_aluminum_kw:
372
- return ValidationResult(
373
- entry_number=entry_number,
374
- description=description,
375
- primary_hts=primary_hts,
376
- additional_hts=additional_hts,
377
- scenario_id="S7a",
378
- scenario_summary=SCENARIO_SUMMARIES["S7a"],
379
- status="FLAG",
380
- expected_hts=[],
381
- missing_hts=[],
382
- unexpected_hts=[],
383
- issue="AMBIGUOUS: Description contains both steel and aluminum keywords - manual review required"
384
- )
385
-
386
- # S7: Steel keyword
387
- if has_metal_kw:
388
- expected = [GENERAL_301_CODE] + list(STEEL_232_CODES)
389
- issues = []
390
-
391
- if not has_301:
392
- issues.append("Missing 99030133")
393
- if not has_steel_232:
394
- issues.append("Missing steel 232 tariff (99038190/91)")
395
- if has_aluminum_232:
396
- issues.append("Should NOT have aluminum 232 when description says steel")
397
- if has_mismatch:
398
- issues.append("Should NOT have 99030125 with correct steel classification")
399
-
400
- status = "PASS" if not issues else "FAIL"
401
- return ValidationResult(
402
- entry_number=entry_number,
403
- description=description,
404
- primary_hts=primary_hts,
405
- additional_hts=additional_hts,
406
- scenario_id="S7",
407
- scenario_summary=SCENARIO_SUMMARIES["S7"],
408
- status=status,
409
- expected_hts=expected,
410
- missing_hts=self._get_missing_codes([GENERAL_301_CODE], has_301, STEEL_232_CODES, has_steel_232),
411
- unexpected_hts=list(ALUMINUM_232_CODES & additional_set) + ([MISMATCH_CODE] if has_mismatch else []),
412
- issue="; ".join(issues) if issues else "Correct - dual ST/AL with steel keyword"
413
- )
414
-
415
- # S7: Aluminum keyword
416
- if has_aluminum_kw:
417
- expected = [GENERAL_301_CODE] + list(ALUMINUM_232_CODES)
418
- issues = []
419
-
420
- if not has_301:
421
- issues.append("Missing 99030133")
422
- if not has_aluminum_232:
423
- issues.append("Missing aluminum 232 tariff (99038507/08)")
424
- if has_steel_232:
425
- issues.append("Should NOT have steel 232 when description says aluminum")
426
- if has_mismatch:
427
- issues.append("Should NOT have 99030125 with correct aluminum classification")
428
-
429
- status = "PASS" if not issues else "FAIL"
430
- return ValidationResult(
431
- entry_number=entry_number,
432
- description=description,
433
- primary_hts=primary_hts,
434
- additional_hts=additional_hts,
435
- scenario_id="S7",
436
- scenario_summary=SCENARIO_SUMMARIES["S7"],
437
- status=status,
438
- expected_hts=expected,
439
- missing_hts=self._get_missing_codes([GENERAL_301_CODE], has_301, ALUMINUM_232_CODES, has_aluminum_232),
440
- unexpected_hts=list(STEEL_232_CODES & additional_set) + ([MISMATCH_CODE] if has_mismatch else []),
441
- issue="; ".join(issues) if issues else "Correct - dual ST/AL with aluminum keyword"
442
  )
443
 
444
- # S8: Neither keyword - FLAG for manual review
445
- return ValidationResult(
446
- entry_number=entry_number,
447
- description=description,
448
- primary_hts=primary_hts,
449
- additional_hts=additional_hts,
450
- scenario_id="S8",
451
- scenario_summary=SCENARIO_SUMMARIES["S8"],
452
- status="FLAG",
453
- expected_hts=[],
454
- missing_hts=[],
455
- unexpected_hts=[],
456
- issue="HTS in both Steel+Aluminum list but no metal/aluminum keywords - manual review required"
457
- )
458
-
459
- # Priority 3: Single list scenarios
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
 
461
- # Steel scenarios (S1, S15)
462
- if in_steel and not in_aluminum and not in_copper:
463
- # S15: Steel HTS + aluminum keyword - mismatch, should apply 99030125
464
- if has_aluminum_kw and not has_metal_kw:
465
- expected = [MISMATCH_CODE]
466
- issues = []
467
-
468
- if not has_mismatch:
469
- issues.append("Missing 99030125")
470
- if has_steel_232 or has_aluminum_232:
471
- issues.append("Should NOT have 232 tariffs - description says aluminum but HTS is steel")
472
- if has_301:
473
- issues.append("Should NOT have 99030133 - description says aluminum but HTS is steel")
474
-
475
- status = "PASS" if not issues else "FAIL"
476
- return ValidationResult(
477
- entry_number=entry_number,
478
- description=description,
479
- primary_hts=primary_hts,
480
- additional_hts=additional_hts,
481
- scenario_id="S15",
482
- scenario_summary=SCENARIO_SUMMARIES["S15"],
483
- status=status,
484
- expected_hts=expected,
485
- missing_hts=[MISMATCH_CODE] if not has_mismatch else [],
486
- unexpected_hts=self._get_unexpected_232(additional_set) + ([GENERAL_301_CODE] if has_301 else []),
487
- issue="; ".join(issues) if issues else "Correct - steel HTS with aluminum keyword, has 99030125"
488
- )
489
-
490
- if has_metal_kw:
491
- # S1: Steel HTS + metal keyword + 232 tariff
492
- if has_steel_232:
493
- expected = [GENERAL_301_CODE]
494
- issues = []
495
-
496
- if not has_301:
497
- issues.append("Missing 99030133")
498
- if has_mismatch:
499
- issues.append("Should NOT have 99030125 with correct steel classification")
500
-
501
- status = "PASS" if not issues else "FAIL"
502
- return ValidationResult(
503
- entry_number=entry_number,
504
- description=description,
505
- primary_hts=primary_hts,
506
- additional_hts=additional_hts,
507
- scenario_id="S1",
508
- scenario_summary=SCENARIO_SUMMARIES["S1"],
509
- status=status,
510
- expected_hts=[GENERAL_301_CODE] + list(STEEL_232_CODES),
511
- missing_hts=[GENERAL_301_CODE] if not has_301 else [],
512
- unexpected_hts=[MISMATCH_CODE] if has_mismatch else [],
513
- issue="; ".join(issues) if issues else "Correct - steel HTS + keyword + 232"
514
- )
515
- else:
516
- # Steel HTS + metal keyword but no 232 - should have 232
517
- issues = ["Missing steel 232 tariff (99038190/91)"]
518
- if not has_301:
519
- issues.append("Missing 99030133")
520
-
521
- return ValidationResult(
522
- entry_number=entry_number,
523
- description=description,
524
- primary_hts=primary_hts,
525
- additional_hts=additional_hts,
526
- scenario_id="S1",
527
- scenario_summary=SCENARIO_SUMMARIES["S1"],
528
- status="FAIL",
529
- expected_hts=[GENERAL_301_CODE] + list(STEEL_232_CODES),
530
- missing_hts=[GENERAL_301_CODE] + list(STEEL_232_CODES) if not has_301 else list(STEEL_232_CODES),
531
- unexpected_hts=[],
532
- issue="; ".join(issues)
533
- )
534
-
535
- # S2: Metal keyword but NOT in steel list
536
- if has_metal_kw and not in_steel:
537
- expected = [MISMATCH_CODE]
538
- issues = []
539
 
540
- if not has_mismatch:
541
- issues.append("Missing 99030125")
542
- if has_steel_232:
543
- issues.append("Should NOT have steel 232 tariff - HTS not in steel list")
544
 
545
- status = "PASS" if not issues else "FAIL"
546
- return ValidationResult(
547
- entry_number=entry_number,
548
- description=description,
549
- primary_hts=primary_hts,
550
- additional_hts=additional_hts,
551
- scenario_id="S2",
552
- scenario_summary=SCENARIO_SUMMARIES["S2"],
553
- status=status,
554
- expected_hts=expected,
555
- missing_hts=[MISMATCH_CODE] if not has_mismatch else [],
556
- unexpected_hts=list(STEEL_232_CODES & additional_set),
557
- issue="; ".join(issues) if issues else "Correct - metal keyword with non-steel HTS"
558
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
 
560
- # Aluminum scenarios (S4, S16)
561
  if in_aluminum and not in_steel and not in_copper:
562
- # S16: Aluminum HTS + steel keyword - mismatch, should apply 99030125
563
- if has_metal_kw and not has_aluminum_kw:
564
- expected = [MISMATCH_CODE]
565
- issues = []
566
-
567
- if not has_mismatch:
568
- issues.append("Missing 99030125")
569
- if has_steel_232 or has_aluminum_232:
570
- issues.append("Should NOT have 232 tariffs - description says steel but HTS is aluminum")
571
- if has_301:
572
- issues.append("Should NOT have 99030133 - description says steel but HTS is aluminum")
573
-
574
- status = "PASS" if not issues else "FAIL"
575
- return ValidationResult(
576
- entry_number=entry_number,
577
- description=description,
578
- primary_hts=primary_hts,
579
- additional_hts=additional_hts,
580
- scenario_id="S16",
581
- scenario_summary=SCENARIO_SUMMARIES["S16"],
582
- status=status,
583
- expected_hts=expected,
584
- missing_hts=[MISMATCH_CODE] if not has_mismatch else [],
585
- unexpected_hts=self._get_unexpected_232(additional_set) + ([GENERAL_301_CODE] if has_301 else []),
586
- issue="; ".join(issues) if issues else "Correct - aluminum HTS with steel keyword, has 99030125"
587
- )
588
-
589
- if has_aluminum_kw:
590
- # S4: Aluminum HTS + aluminum keyword + 232 tariff
591
- if has_aluminum_232:
592
- expected = [GENERAL_301_CODE]
593
- issues = []
594
-
595
- if not has_301:
596
- issues.append("Missing 99030133")
597
- if has_mismatch:
598
- issues.append("Should NOT have 99030125 with correct aluminum classification")
599
-
600
- status = "PASS" if not issues else "FAIL"
601
- return ValidationResult(
602
- entry_number=entry_number,
603
- description=description,
604
- primary_hts=primary_hts,
605
- additional_hts=additional_hts,
606
- scenario_id="S4",
607
- scenario_summary=SCENARIO_SUMMARIES["S4"],
608
- status=status,
609
- expected_hts=[GENERAL_301_CODE] + list(ALUMINUM_232_CODES),
610
- missing_hts=[GENERAL_301_CODE] if not has_301 else [],
611
- unexpected_hts=[MISMATCH_CODE] if has_mismatch else [],
612
- issue="; ".join(issues) if issues else "Correct - aluminum HTS + keyword + 232"
613
- )
614
- else:
615
- # Aluminum HTS + keyword but no 232
616
- issues = ["Missing aluminum 232 tariff (99038507/08)"]
617
- if not has_301:
618
- issues.append("Missing 99030133")
619
-
620
- return ValidationResult(
621
- entry_number=entry_number,
622
- description=description,
623
- primary_hts=primary_hts,
624
- additional_hts=additional_hts,
625
- scenario_id="S4",
626
- scenario_summary=SCENARIO_SUMMARIES["S4"],
627
- status="FAIL",
628
- expected_hts=[GENERAL_301_CODE] + list(ALUMINUM_232_CODES),
629
- missing_hts=[GENERAL_301_CODE] + list(ALUMINUM_232_CODES) if not has_301 else list(ALUMINUM_232_CODES),
630
- unexpected_hts=[],
631
- issue="; ".join(issues)
632
- )
633
-
634
- # S5: Aluminum keyword but NOT in aluminum list
635
- if has_aluminum_kw and not in_aluminum:
636
- expected = [MISMATCH_CODE]
637
- issues = []
638
-
639
- if not has_mismatch:
640
- issues.append("Missing 99030125")
641
- if has_aluminum_232:
642
- issues.append("Should NOT have aluminum 232 tariff - HTS not in aluminum list")
643
-
644
- status = "PASS" if not issues else "FAIL"
645
- return ValidationResult(
646
- entry_number=entry_number,
647
- description=description,
648
- primary_hts=primary_hts,
649
- additional_hts=additional_hts,
650
- scenario_id="S5",
651
- scenario_summary=SCENARIO_SUMMARIES["S5"],
652
- status=status,
653
- expected_hts=expected,
654
- missing_hts=[MISMATCH_CODE] if not has_mismatch else [],
655
- unexpected_hts=list(ALUMINUM_232_CODES & additional_set),
656
- issue="; ".join(issues) if issues else "Correct - aluminum keyword with non-aluminum HTS"
657
- )
658
 
659
- # Copper scenarios (COPPER_OK, S17)
660
  if in_copper and not in_steel and not in_aluminum:
661
- if has_copper_kw:
662
- # COPPER_OK: Copper HTS + copper keyword
663
- expected = list(COPPER_CODES)
664
- issues = []
665
-
666
- if not has_copper_tariff:
667
- issues.append("Missing copper tariff (99037801/02)")
668
-
669
- status = "PASS" if not issues else "FAIL"
670
- return ValidationResult(
671
- entry_number=entry_number,
672
- description=description,
673
- primary_hts=primary_hts,
674
- additional_hts=additional_hts,
675
- scenario_id="COPPER_OK",
676
- scenario_summary=SCENARIO_SUMMARIES["COPPER_OK"],
677
- status=status,
678
- expected_hts=expected,
679
- missing_hts=list(COPPER_CODES) if not has_copper_tariff else [],
680
- unexpected_hts=[],
681
- issue="; ".join(issues) if issues else "Correct - copper HTS + keyword"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  )
683
- else:
684
- # S17: Copper HTS but NO copper keyword - apply copper tariffs + 99030133, no 99030125
685
- expected = list(COPPER_CODES) + [GENERAL_301_CODE]
686
- issues = []
687
-
688
- if not has_copper_tariff:
689
- issues.append("Missing copper tariff (99037801/02)")
690
- if not has_301:
691
- issues.append("Missing 99030133 - copper HTS without copper keyword")
692
- if has_mismatch:
693
- issues.append("Should NOT have 99030125 - no mismatch expected for copper HTS")
694
-
695
- status = "PASS" if not issues else "FAIL"
696
- return ValidationResult(
697
- entry_number=entry_number,
698
- description=description,
699
- primary_hts=primary_hts,
700
- additional_hts=additional_hts,
701
- scenario_id="S17",
702
- scenario_summary=SCENARIO_SUMMARIES["S17"],
703
- status=status,
704
- expected_hts=expected,
705
- missing_hts=(list(COPPER_CODES) if not has_copper_tariff else []) + ([GENERAL_301_CODE] if not has_301 else []),
706
- unexpected_hts=[MISMATCH_CODE] if has_mismatch else [],
707
- issue="; ".join(issues) if issues else "Correct - copper HTS without keyword, has copper tariffs + 99030133"
708
- )
709
-
710
- # S9: Copper keyword but NOT in copper list
711
- if has_copper_kw and not in_copper:
712
- expected = [MISMATCH_CODE]
713
- issues = []
714
-
715
- if not has_mismatch:
716
- issues.append("Missing 99030125")
717
- if has_steel_232 or has_aluminum_232 or has_copper_tariff:
718
- issues.append("Should NOT have 232/copper tariffs - HTS not in copper list")
719
- if has_301:
720
- issues.append("Should NOT have 99030133 - HTS not in copper list")
721
 
722
- status = "PASS" if not issues else "FAIL"
723
- return ValidationResult(
724
- entry_number=entry_number,
725
- description=description,
726
- primary_hts=primary_hts,
727
- additional_hts=additional_hts,
728
- scenario_id="S9",
729
- scenario_summary=SCENARIO_SUMMARIES["S9"],
730
- status=status,
731
- expected_hts=expected,
732
- missing_hts=[MISMATCH_CODE] if not has_mismatch else [],
733
- unexpected_hts=self._get_unexpected_232(additional_set) + ([GENERAL_301_CODE] if has_301 else []),
734
- issue="; ".join(issues) if issues else "Correct - copper keyword with non-copper HTS"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  )
736
 
737
- # No applicable scenario
738
  return ValidationResult(
739
  entry_number=entry_number,
740
  description=description,
@@ -746,24 +1113,19 @@ class HTSValidator:
746
  expected_hts=[],
747
  missing_hts=[],
748
  unexpected_hts=[],
749
- issue="No metal-related validation required"
 
 
 
 
 
 
 
 
 
 
750
  )
751
 
752
- def _get_unexpected_232(self, additional_set: Set[str]) -> List[str]:
753
- """Get list of 232 tariffs that shouldn't be present"""
754
- all_232 = STEEL_232_CODES | ALUMINUM_232_CODES | COPPER_CODES
755
- return list(all_232 & additional_set)
756
-
757
- def _get_missing_codes(self, fixed_codes: List[str], has_fixed: bool,
758
- variable_codes: Set[str], has_variable: bool) -> List[str]:
759
- """Get list of missing codes"""
760
- missing = []
761
- if not has_fixed:
762
- missing.extend(fixed_codes)
763
- if not has_variable:
764
- missing.extend(list(variable_codes))
765
- return missing
766
-
767
 
768
  def validate_dataframe(df, validator: HTSValidator,
769
  description_col: str = "Description",
 
1
  """
2
  HTS Validator - Core validation logic for HTS tariff auditing
3
  Validates primary HTS codes against additional HTS and description keywords
4
+
5
+ Logic Flow:
6
+ 1. Check Override Keywords (Zinc, Plastics) - highest priority
7
+ 2. Check Special HTS (Computer Parts, Auto Parts)
8
+ 3. Check Primary HTS membership:
9
+ a. If in 2+ HTS categories -> Dual HTS logic
10
+ b. If in 1 HTS category -> Single HTS logic
11
+ c. If in 0 HTS categories -> Keyword-only logic
12
+ 4. Within each HTS category, check description keywords
13
  """
14
 
15
  import re
 
26
  GENERAL_301_CODE = "99030133"
27
  MISMATCH_CODE = "99030125"
28
 
29
+ # All 232/tariff codes for checking forbidden
30
+ ALL_232_CODES = STEEL_232_CODES | ALUMINUM_232_CODES | COPPER_CODES
31
+
32
+ # Scenario summaries - updated for new case IDs
33
  SCENARIO_SUMMARIES = {
34
+ # Level 0: Override
35
+ "Z1": "Zinc keyword - only 99030125, no 232/copper tariffs",
36
+ "P1": "Plastics + Steel HTS - only 99030125, no 232",
37
+ "P2": "Plastics + Aluminum HTS - only 99030125, no 232",
38
+ "P3": "Plastics + Steel+Alum HTS - only 99030125, no 232",
39
+ "P4": "Plastics + no metal HTS - no action",
40
+ "P5": "Plastics + Copper HTS - only 99030125, no copper tariff",
41
+ "P6": "Plastics + Alum+Copper HTS - only 99030125, no 232/copper",
42
+ # Level 1: Special HTS
43
+ "C1": "Computer Parts HTS - FLAG for manual review",
44
+ "A1": "Auto Parts HTS - FLAG for manual review",
45
+ # Level 2: Dual HTS - Steel + Aluminum
46
+ "D1": "Steel+Alum HTS, no keyword - FLAG",
47
+ "D2": "Steel+Alum HTS + metal keyword - Steel 232 + 99030133",
48
+ "D3": "Steel+Alum HTS + aluminum keyword - Alum 232 + 99030133",
49
+ "D4": "Steel+Alum HTS + copper keyword - 99030125 (mismatch)",
50
+ "D5": "Steel+Alum HTS + metal+alum keywords - FLAG ambiguous",
51
+ "D6": "Steel+Alum HTS + metal+copper keywords - Steel 232 + 99030133",
52
+ "D7": "Steel+Alum HTS + alum+copper keywords - Alum 232 + 99030133",
53
+ "D8": "Steel+Alum HTS + all keywords - FLAG ambiguous",
54
+ # Level 2: Dual HTS - Aluminum + Copper
55
+ "E1": "Alum+Copper HTS, no keyword - FLAG",
56
+ "E2": "Alum+Copper HTS + metal keyword - 99030125 (mismatch)",
57
+ "E3": "Alum+Copper HTS + aluminum keyword - Alum 232 + 99030133",
58
+ "E4": "Alum+Copper HTS + copper keyword - Copper + 99030133",
59
+ "E5": "Alum+Copper HTS + metal+alum keywords - Alum 232 + 99030133",
60
+ "E6": "Alum+Copper HTS + metal+copper keywords - Copper + 99030133",
61
+ "E7": "Alum+Copper HTS + alum+copper keywords - FLAG ambiguous",
62
+ "E8": "Alum+Copper HTS + all keywords - FLAG ambiguous",
63
+ # Level 2: Dual HTS - Steel + Copper
64
+ "F1": "Steel+Copper HTS, no keyword - FLAG",
65
+ "F2": "Steel+Copper HTS + metal keyword - Steel 232 + 99030133",
66
+ "F3": "Steel+Copper HTS + aluminum keyword - 99030125 (mismatch)",
67
+ "F4": "Steel+Copper HTS + copper keyword - Copper + 99030133",
68
+ "F5": "Steel+Copper HTS + metal+alum keywords - Steel 232 + 99030133",
69
+ "F6": "Steel+Copper HTS + metal+copper keywords - FLAG ambiguous",
70
+ "F7": "Steel+Copper HTS + alum+copper keywords - Copper + 99030133",
71
+ "F8": "Steel+Copper HTS + all keywords - FLAG ambiguous",
72
+ # Level 3: Single HTS - Steel
73
+ "S1": "Steel HTS, no keyword - Steel 232 + 99030133",
74
+ "S2": "Steel HTS + metal keyword - Steel 232 + 99030133",
75
+ "S3": "Steel HTS + aluminum keyword - 99030125 (mismatch)",
76
+ "S4": "Steel HTS + copper keyword - 99030125 (mismatch)",
77
+ "S5": "Steel HTS + metal+alum keywords - Steel 232 + 99030133",
78
+ "S6": "Steel HTS + metal+copper keywords - Steel 232 + 99030133",
79
+ "S7": "Steel HTS + alum+copper keywords - 99030125 (mismatch)",
80
+ "S8": "Steel HTS + all keywords - Steel 232 + 99030133",
81
+ # Level 3: Single HTS - Aluminum
82
+ "L1": "Aluminum HTS, no keyword - Alum 232 + 99030133",
83
+ "L2": "Aluminum HTS + metal keyword - 99030125 (mismatch)",
84
+ "L3": "Aluminum HTS + aluminum keyword - Alum 232 + 99030133",
85
+ "L4": "Aluminum HTS + copper keyword - 99030125 (mismatch)",
86
+ "L5": "Aluminum HTS + metal+alum keywords - Alum 232 + 99030133",
87
+ "L6": "Aluminum HTS + metal+copper keywords - 99030125 (mismatch)",
88
+ "L7": "Aluminum HTS + alum+copper keywords - Alum 232 + 99030133",
89
+ "L8": "Aluminum HTS + all keywords - Alum 232 + 99030133",
90
+ # Level 3: Single HTS - Copper
91
+ "U1": "Copper HTS, no keyword - Copper + 99030133",
92
+ "U2": "Copper HTS + metal keyword - 99030125 (mismatch)",
93
+ "U3": "Copper HTS + aluminum keyword - 99030125 (mismatch)",
94
+ "U4": "Copper HTS + copper keyword - Copper tariff",
95
+ "U5": "Copper HTS + metal+alum keywords - 99030125 (mismatch)",
96
+ "U6": "Copper HTS + metal+copper keywords - Copper tariff",
97
+ "U7": "Copper HTS + alum+copper keywords - Copper tariff",
98
+ "U8": "Copper HTS + all keywords - Copper tariff",
99
+ # Level 4: No HTS Match
100
+ "N1": "No metal HTS, no keyword - no action",
101
+ "N2": "No metal HTS + metal keyword - 99030125",
102
+ "N3": "No metal HTS + aluminum keyword - 99030125",
103
+ "N4": "No metal HTS + copper keyword - 99030125",
104
+ "N5": "No metal HTS + metal+alum keywords - 99030125",
105
+ "N6": "No metal HTS + metal+copper keywords - 99030125",
106
+ "N7": "No metal HTS + alum+copper keywords - 99030125",
107
+ "N8": "No metal HTS + all keywords - 99030125",
108
+ # Legacy
109
  "NONE": "No applicable scenario - entry does not match any validation rules",
110
  }
111
 
 
119
  additional_hts: List[str]
120
  scenario_id: str
121
  scenario_summary: str
122
+ status: str # PASS, FLAG
123
  expected_hts: List[str]
124
  missing_hts: List[str]
125
  unexpected_hts: List[str]
126
  issue: str
127
+ # HTS membership indicators
128
+ in_steel_hts: bool = False
129
+ in_aluminum_hts: bool = False
130
+ in_copper_hts: bool = False
131
+ in_computer_hts: bool = False
132
+ in_auto_hts: bool = False
133
+ # Keyword indicators
134
+ has_metal_keyword: bool = False
135
+ has_aluminum_keyword: bool = False
136
+ has_copper_keyword: bool = False
137
+ has_zinc_keyword: bool = False
138
+ has_plastics_keyword: bool = False
139
 
140
 
141
  class HTSValidator:
 
232
  """Check if any of the HTS codes are present"""
233
  return bool(hts_codes & additional_set)
234
 
235
+ def _get_keyword_category(self, has_metal: bool, has_aluminum: bool, has_copper: bool) -> str:
236
+ """Determine keyword category code (K0-K7)"""
237
+ if has_metal and has_aluminum and has_copper:
238
+ return "K7"
239
+ elif has_aluminum and has_copper:
240
+ return "K6"
241
+ elif has_metal and has_copper:
242
+ return "K5"
243
+ elif has_metal and has_aluminum:
244
+ return "K4"
245
+ elif has_copper:
246
+ return "K3"
247
+ elif has_aluminum:
248
+ return "K2"
249
+ elif has_metal:
250
+ return "K1"
251
+ else:
252
+ return "K0"
253
+
254
  def validate_entry(self, entry_number: str, description: str,
255
  primary_hts: str, additional_hts: List[str]) -> ValidationResult:
256
  """Validate a single entry against all scenarios"""
 
281
  has_301 = self._check_hts_present(GENERAL_301_CODE, additional_set)
282
  has_mismatch = self._check_hts_present(MISMATCH_CODE, additional_set)
283
 
284
+ # Get keyword category
285
+ keyword_cat = self._get_keyword_category(has_metal_kw, has_aluminum_kw, has_copper_kw)
286
+
287
+ # Apply validation rules in level order
288
  return self._apply_validation_rules(
289
  entry_number=entry_number,
290
  description=desc,
 
305
  has_copper_tariff=has_copper_tariff,
306
  has_301=has_301,
307
  has_mismatch=has_mismatch,
308
+ additional_set=additional_set,
309
+ keyword_cat=keyword_cat
310
+ )
311
+
312
+ def _create_result(self, entry_number: str, description: str, primary_hts: str,
313
+ additional_hts: List[str], scenario_id: str,
314
+ expected_codes: List[str], forbidden_codes: Set[str],
315
+ additional_set: Set[str], always_flag: bool = False,
316
+ flag_reason: str = "",
317
+ # Indicators
318
+ in_steel: bool = False, in_aluminum: bool = False, in_copper: bool = False,
319
+ in_computer: bool = False, in_auto: bool = False,
320
+ has_metal_kw: bool = False, has_aluminum_kw: bool = False,
321
+ has_copper_kw: bool = False, has_zinc_kw: bool = False,
322
+ has_plastics_kw: bool = False) -> ValidationResult:
323
+ """Create validation result by checking expected vs actual
324
+
325
+ For tariff code groups (Steel 232, Aluminum 232, Copper), we check if ANY is present.
326
+ For individual codes (99030125, 99030133), we check if that specific code is present.
327
+ """
328
+
329
+ if always_flag:
330
+ return ValidationResult(
331
+ entry_number=entry_number,
332
+ description=description,
333
+ primary_hts=primary_hts,
334
+ additional_hts=additional_hts,
335
+ scenario_id=scenario_id,
336
+ scenario_summary=SCENARIO_SUMMARIES.get(scenario_id, ""),
337
+ status="FLAG",
338
+ expected_hts=[],
339
+ missing_hts=[],
340
+ unexpected_hts=[],
341
+ issue=flag_reason or "Manual review required",
342
+ in_steel_hts=in_steel,
343
+ in_aluminum_hts=in_aluminum,
344
+ in_copper_hts=in_copper,
345
+ in_computer_hts=in_computer,
346
+ in_auto_hts=in_auto,
347
+ has_metal_keyword=has_metal_kw,
348
+ has_aluminum_keyword=has_aluminum_kw,
349
+ has_copper_keyword=has_copper_kw,
350
+ has_zinc_keyword=has_zinc_kw,
351
+ has_plastics_keyword=has_plastics_kw
352
+ )
353
+
354
+ # Group expected codes by tariff type
355
+ # Check if ANY code from each group is present
356
+ missing = []
357
+ expected_display = []
358
+
359
+ # Check Steel 232 group
360
+ steel_232_expected = [c for c in expected_codes if c in STEEL_232_CODES]
361
+ if steel_232_expected:
362
+ expected_display.append("Steel 232")
363
+ if not (STEEL_232_CODES & additional_set):
364
+ missing.append("Steel 232 (99038190/91)")
365
+
366
+ # Check Aluminum 232 group
367
+ alum_232_expected = [c for c in expected_codes if c in ALUMINUM_232_CODES]
368
+ if alum_232_expected:
369
+ expected_display.append("Alum 232")
370
+ if not (ALUMINUM_232_CODES & additional_set):
371
+ missing.append("Alum 232 (99038507/08)")
372
+
373
+ # Check Copper group
374
+ copper_expected = [c for c in expected_codes if c in COPPER_CODES]
375
+ if copper_expected:
376
+ expected_display.append("Copper")
377
+ if not (COPPER_CODES & additional_set):
378
+ missing.append("Copper (99037801/02)")
379
+
380
+ # Check individual codes (99030133, 99030125)
381
+ for code in expected_codes:
382
+ if code not in STEEL_232_CODES and code not in ALUMINUM_232_CODES and code not in COPPER_CODES:
383
+ expected_display.append(code)
384
+ if code not in additional_set:
385
+ missing.append(code)
386
+
387
+ # Check for forbidden codes present
388
+ unexpected = list(forbidden_codes & additional_set)
389
+
390
+ # Determine status
391
+ if not missing and not unexpected:
392
+ status = "PASS"
393
+ issue = "Correct tariff application"
394
+ else:
395
+ status = "FLAG"
396
+ issues = []
397
+ if missing:
398
+ issues.append(f"Missing: {', '.join(missing)}")
399
+ if unexpected:
400
+ issues.append(f"Unexpected: {', '.join(unexpected)}")
401
+ issue = "; ".join(issues)
402
+
403
+ return ValidationResult(
404
+ entry_number=entry_number,
405
+ description=description,
406
+ primary_hts=primary_hts,
407
+ additional_hts=additional_hts,
408
+ scenario_id=scenario_id,
409
+ scenario_summary=SCENARIO_SUMMARIES.get(scenario_id, ""),
410
+ status=status,
411
+ expected_hts=expected_display,
412
+ missing_hts=missing,
413
+ unexpected_hts=unexpected,
414
+ issue=issue,
415
+ in_steel_hts=in_steel,
416
+ in_aluminum_hts=in_aluminum,
417
+ in_copper_hts=in_copper,
418
+ in_computer_hts=in_computer,
419
+ in_auto_hts=in_auto,
420
+ has_metal_keyword=has_metal_kw,
421
+ has_aluminum_keyword=has_aluminum_kw,
422
+ has_copper_keyword=has_copper_kw,
423
+ has_zinc_keyword=has_zinc_kw,
424
+ has_plastics_keyword=has_plastics_kw
425
  )
426
 
427
  def _apply_validation_rules(self, entry_number: str, description: str,
 
433
  has_plastics_kw: bool, has_steel_232: bool,
434
  has_aluminum_232: bool, has_copper_tariff: bool,
435
  has_301: bool, has_mismatch: bool,
436
+ additional_set: Set[str], keyword_cat: str) -> ValidationResult:
437
+ """Apply all validation rules in level order"""
438
+
439
+ # Common indicator parameters for all _create_result calls
440
+ indicators = {
441
+ "in_steel": in_steel,
442
+ "in_aluminum": in_aluminum,
443
+ "in_copper": in_copper,
444
+ "in_computer": in_computer_parts,
445
+ "in_auto": in_auto_parts,
446
+ "has_metal_kw": has_metal_kw,
447
+ "has_aluminum_kw": has_aluminum_kw,
448
+ "has_copper_kw": has_copper_kw,
449
+ "has_zinc_kw": has_zinc_kw,
450
+ "has_plastics_kw": has_plastics_kw,
451
+ }
452
+
453
+ # =====================================================================
454
+ # LEVEL 0: Override Cases (Highest Priority)
455
+ # =====================================================================
456
+
457
+ # Z1: Zinc keyword - only 99030125, no 232/copper tariffs
458
  if has_zinc_kw:
459
+ return self._create_result(
460
+ entry_number, description, primary_hts, additional_hts,
461
+ scenario_id="Z1",
462
+ expected_codes=[MISMATCH_CODE],
463
+ forbidden_codes=ALL_232_CODES,
464
+ additional_set=additional_set,
465
+ **indicators
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  )
467
 
468
+ # Plastics override cases
469
+ if has_plastics_kw:
470
+ if in_steel and not in_aluminum and not in_copper:
471
+ # P1: Plastics + Steel HTS
472
+ return self._create_result(
473
+ entry_number, description, primary_hts, additional_hts,
474
+ scenario_id="P1",
475
+ expected_codes=[MISMATCH_CODE],
476
+ forbidden_codes=STEEL_232_CODES | ALUMINUM_232_CODES,
477
+ additional_set=additional_set,
478
+ **indicators
479
+ )
480
+ elif in_aluminum and not in_steel and not in_copper:
481
+ # P2: Plastics + Aluminum HTS
482
+ return self._create_result(
483
+ entry_number, description, primary_hts, additional_hts,
484
+ scenario_id="P2",
485
+ expected_codes=[MISMATCH_CODE],
486
+ forbidden_codes=STEEL_232_CODES | ALUMINUM_232_CODES,
487
+ additional_set=additional_set,
488
+ **indicators
489
+ )
490
+ elif in_steel and in_aluminum and not in_copper:
491
+ # P3: Plastics + Steel+Alum HTS
492
+ return self._create_result(
493
+ entry_number, description, primary_hts, additional_hts,
494
+ scenario_id="P3",
495
+ expected_codes=[MISMATCH_CODE],
496
+ forbidden_codes=STEEL_232_CODES | ALUMINUM_232_CODES,
497
+ additional_set=additional_set,
498
+ **indicators
499
+ )
500
+ elif in_copper and not in_steel and not in_aluminum:
501
+ # P5: Plastics + Copper HTS
502
+ return self._create_result(
503
+ entry_number, description, primary_hts, additional_hts,
504
+ scenario_id="P5",
505
+ expected_codes=[MISMATCH_CODE],
506
+ forbidden_codes=COPPER_CODES,
507
+ additional_set=additional_set,
508
+ **indicators
509
+ )
510
+ elif in_aluminum and in_copper:
511
+ # P6: Plastics + Alum+Copper HTS
512
+ return self._create_result(
513
+ entry_number, description, primary_hts, additional_hts,
514
+ scenario_id="P6",
515
+ expected_codes=[MISMATCH_CODE],
516
+ forbidden_codes=ALUMINUM_232_CODES | COPPER_CODES,
517
+ additional_set=additional_set,
518
+ **indicators
519
+ )
520
+ else:
521
+ # P4: Plastics + no metal HTS - no action needed
522
+ return self._create_result(
523
+ entry_number, description, primary_hts, additional_hts,
524
+ scenario_id="P4",
525
+ expected_codes=[],
526
+ forbidden_codes=set(),
527
+ additional_set=additional_set,
528
+ **indicators
529
+ )
530
 
531
+ # =====================================================================
532
+ # LEVEL 1: Special HTS Categories
533
+ # =====================================================================
534
 
535
+ # C1: Computer Parts HTS - always FLAG
536
  if in_computer_parts:
537
+ return self._create_result(
538
+ entry_number, description, primary_hts, additional_hts,
539
+ scenario_id="C1",
540
+ expected_codes=[], forbidden_codes=set(),
541
+ additional_set=additional_set,
542
+ **indicators,
543
+ always_flag=True,
544
+ flag_reason="Computer parts HTS - manual review required"
 
 
 
 
545
  )
546
 
547
+ # A1: Auto Parts HTS - always FLAG
548
  if in_auto_parts:
549
+ return self._create_result(
550
+ entry_number, description, primary_hts, additional_hts,
551
+ scenario_id="A1",
552
+ expected_codes=[], forbidden_codes=set(),
553
+ additional_set=additional_set,
554
+ **indicators,
555
+ always_flag=True,
556
+ flag_reason="Auto parts HTS - manual review required"
 
 
 
 
557
  )
558
 
559
+ # =====================================================================
560
+ # LEVEL 2: Dual HTS Categories
561
+ # =====================================================================
562
+
563
+ # H4: Steel + Aluminum
564
+ if in_steel and in_aluminum and not in_copper:
565
+ if keyword_cat == "K0": # D1: No keyword
566
+ return self._create_result(
567
+ entry_number, description, primary_hts, additional_hts,
568
+ scenario_id="D1",
569
+ expected_codes=[], forbidden_codes=set(),
570
+ additional_set=additional_set,
571
+ **indicators,
572
+ always_flag=True,
573
+ flag_reason="Steel+Aluminum HTS with no keyword - cannot determine tariff"
574
+ )
575
+ elif keyword_cat == "K1": # D2: Metal only
576
+ return self._create_result(
577
+ entry_number, description, primary_hts, additional_hts,
578
+ scenario_id="D2",
579
+ expected_codes=list(STEEL_232_CODES) + [GENERAL_301_CODE],
580
+ forbidden_codes={MISMATCH_CODE},
581
+ additional_set=additional_set,
582
+ **indicators
583
+ )
584
+ elif keyword_cat == "K2": # D3: Aluminum only
585
+ return self._create_result(
586
+ entry_number, description, primary_hts, additional_hts,
587
+ scenario_id="D3",
588
+ expected_codes=list(ALUMINUM_232_CODES) + [GENERAL_301_CODE],
589
+ forbidden_codes={MISMATCH_CODE},
590
+ additional_set=additional_set,
591
+ **indicators
592
+ )
593
+ elif keyword_cat == "K3": # D4: Copper only
594
+ return self._create_result(
595
+ entry_number, description, primary_hts, additional_hts,
596
+ scenario_id="D4",
597
+ expected_codes=[MISMATCH_CODE],
598
+ forbidden_codes=ALL_232_CODES,
599
+ additional_set=additional_set,
600
+ **indicators
601
+ )
602
+ elif keyword_cat == "K4": # D5: Metal + Aluminum
603
+ return self._create_result(
604
+ entry_number, description, primary_hts, additional_hts,
605
+ scenario_id="D5",
606
+ expected_codes=[], forbidden_codes=set(),
607
+ additional_set=additional_set,
608
+ **indicators,
609
+ always_flag=True,
610
+ flag_reason="Steel+Aluminum HTS with both metal and aluminum keywords - ambiguous"
611
+ )
612
+ elif keyword_cat == "K5": # D6: Metal + Copper
613
+ return self._create_result(
614
+ entry_number, description, primary_hts, additional_hts,
615
+ scenario_id="D6",
616
+ expected_codes=list(STEEL_232_CODES) + [GENERAL_301_CODE],
617
+ forbidden_codes={MISMATCH_CODE},
618
+ additional_set=additional_set,
619
+ **indicators
620
+ )
621
+ elif keyword_cat == "K6": # D7: Aluminum + Copper
622
+ return self._create_result(
623
+ entry_number, description, primary_hts, additional_hts,
624
+ scenario_id="D7",
625
+ expected_codes=list(ALUMINUM_232_CODES) + [GENERAL_301_CODE],
626
+ forbidden_codes={MISMATCH_CODE},
627
+ additional_set=additional_set,
628
+ **indicators
629
+ )
630
+ elif keyword_cat == "K7": # D8: All three
631
+ return self._create_result(
632
+ entry_number, description, primary_hts, additional_hts,
633
+ scenario_id="D8",
634
+ expected_codes=[], forbidden_codes=set(),
635
+ additional_set=additional_set,
636
+ **indicators,
637
+ always_flag=True,
638
+ flag_reason="Steel+Aluminum HTS with all keywords - ambiguous"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
  )
640
 
641
+ # H5: Aluminum + Copper
642
+ if in_aluminum and in_copper and not in_steel:
643
+ if keyword_cat == "K0": # E1: No keyword
644
+ return self._create_result(
645
+ entry_number, description, primary_hts, additional_hts,
646
+ scenario_id="E1",
647
+ expected_codes=[], forbidden_codes=set(),
648
+ additional_set=additional_set,
649
+ **indicators,
650
+ always_flag=True,
651
+ flag_reason="Aluminum+Copper HTS with no keyword - cannot determine tariff"
652
+ )
653
+ elif keyword_cat == "K1": # E2: Metal only
654
+ return self._create_result(
655
+ entry_number, description, primary_hts, additional_hts,
656
+ scenario_id="E2",
657
+ expected_codes=[MISMATCH_CODE],
658
+ forbidden_codes=ALL_232_CODES,
659
+ additional_set=additional_set,
660
+ **indicators
661
+ )
662
+ elif keyword_cat == "K2": # E3: Aluminum only
663
+ return self._create_result(
664
+ entry_number, description, primary_hts, additional_hts,
665
+ scenario_id="E3",
666
+ expected_codes=list(ALUMINUM_232_CODES) + [GENERAL_301_CODE],
667
+ forbidden_codes={MISMATCH_CODE},
668
+ additional_set=additional_set,
669
+ **indicators
670
+ )
671
+ elif keyword_cat == "K3": # E4: Copper only
672
+ return self._create_result(
673
+ entry_number, description, primary_hts, additional_hts,
674
+ scenario_id="E4",
675
+ expected_codes=list(COPPER_CODES) + [GENERAL_301_CODE],
676
+ forbidden_codes={MISMATCH_CODE},
677
+ additional_set=additional_set,
678
+ **indicators
679
+ )
680
+ elif keyword_cat == "K4": # E5: Metal + Aluminum
681
+ return self._create_result(
682
+ entry_number, description, primary_hts, additional_hts,
683
+ scenario_id="E5",
684
+ expected_codes=list(ALUMINUM_232_CODES) + [GENERAL_301_CODE],
685
+ forbidden_codes={MISMATCH_CODE},
686
+ additional_set=additional_set,
687
+ **indicators
688
+ )
689
+ elif keyword_cat == "K5": # E6: Metal + Copper
690
+ return self._create_result(
691
+ entry_number, description, primary_hts, additional_hts,
692
+ scenario_id="E6",
693
+ expected_codes=list(COPPER_CODES) + [GENERAL_301_CODE],
694
+ forbidden_codes={MISMATCH_CODE},
695
+ additional_set=additional_set,
696
+ **indicators
697
+ )
698
+ elif keyword_cat == "K6": # E7: Aluminum + Copper
699
+ return self._create_result(
700
+ entry_number, description, primary_hts, additional_hts,
701
+ scenario_id="E7",
702
+ expected_codes=[], forbidden_codes=set(),
703
+ additional_set=additional_set,
704
+ **indicators,
705
+ always_flag=True,
706
+ flag_reason="Aluminum+Copper HTS with both aluminum and copper keywords - ambiguous"
707
+ )
708
+ elif keyword_cat == "K7": # E8: All three
709
+ return self._create_result(
710
+ entry_number, description, primary_hts, additional_hts,
711
+ scenario_id="E8",
712
+ expected_codes=[], forbidden_codes=set(),
713
+ additional_set=additional_set,
714
+ **indicators,
715
+ always_flag=True,
716
+ flag_reason="Aluminum+Copper HTS with all keywords - ambiguous"
717
+ )
718
 
719
+ # H6: Steel + Copper
720
+ if in_steel and in_copper and not in_aluminum:
721
+ if keyword_cat == "K0": # F1: No keyword
722
+ return self._create_result(
723
+ entry_number, description, primary_hts, additional_hts,
724
+ scenario_id="F1",
725
+ expected_codes=[], forbidden_codes=set(),
726
+ additional_set=additional_set,
727
+ **indicators,
728
+ always_flag=True,
729
+ flag_reason="Steel+Copper HTS with no keyword - cannot determine tariff"
730
+ )
731
+ elif keyword_cat == "K1": # F2: Metal only
732
+ return self._create_result(
733
+ entry_number, description, primary_hts, additional_hts,
734
+ scenario_id="F2",
735
+ expected_codes=list(STEEL_232_CODES) + [GENERAL_301_CODE],
736
+ forbidden_codes={MISMATCH_CODE},
737
+ additional_set=additional_set,
738
+ **indicators
739
+ )
740
+ elif keyword_cat == "K2": # F3: Aluminum only
741
+ return self._create_result(
742
+ entry_number, description, primary_hts, additional_hts,
743
+ scenario_id="F3",
744
+ expected_codes=[MISMATCH_CODE],
745
+ forbidden_codes=ALL_232_CODES,
746
+ additional_set=additional_set,
747
+ **indicators
748
+ )
749
+ elif keyword_cat == "K3": # F4: Copper only
750
+ return self._create_result(
751
+ entry_number, description, primary_hts, additional_hts,
752
+ scenario_id="F4",
753
+ expected_codes=list(COPPER_CODES) + [GENERAL_301_CODE],
754
+ forbidden_codes={MISMATCH_CODE},
755
+ additional_set=additional_set,
756
+ **indicators
757
+ )
758
+ elif keyword_cat == "K4": # F5: Metal + Aluminum
759
+ return self._create_result(
760
+ entry_number, description, primary_hts, additional_hts,
761
+ scenario_id="F5",
762
+ expected_codes=list(STEEL_232_CODES) + [GENERAL_301_CODE],
763
+ forbidden_codes={MISMATCH_CODE},
764
+ additional_set=additional_set,
765
+ **indicators
766
+ )
767
+ elif keyword_cat == "K5": # F6: Metal + Copper
768
+ return self._create_result(
769
+ entry_number, description, primary_hts, additional_hts,
770
+ scenario_id="F6",
771
+ expected_codes=[], forbidden_codes=set(),
772
+ additional_set=additional_set,
773
+ **indicators,
774
+ always_flag=True,
775
+ flag_reason="Steel+Copper HTS with both metal and copper keywords - ambiguous"
776
+ )
777
+ elif keyword_cat == "K6": # F7: Aluminum + Copper
778
+ return self._create_result(
779
+ entry_number, description, primary_hts, additional_hts,
780
+ scenario_id="F7",
781
+ expected_codes=list(COPPER_CODES) + [GENERAL_301_CODE],
782
+ forbidden_codes={MISMATCH_CODE},
783
+ additional_set=additional_set,
784
+ **indicators
785
+ )
786
+ elif keyword_cat == "K7": # F8: All three
787
+ return self._create_result(
788
+ entry_number, description, primary_hts, additional_hts,
789
+ scenario_id="F8",
790
+ expected_codes=[], forbidden_codes=set(),
791
+ additional_set=additional_set,
792
+ **indicators,
793
+ always_flag=True,
794
+ flag_reason="Steel+Copper HTS with all keywords - ambiguous"
795
+ )
 
796
 
797
+ # =====================================================================
798
+ # LEVEL 3: Single HTS Category
799
+ # =====================================================================
 
800
 
801
+ # H1: Steel Only
802
+ if in_steel and not in_aluminum and not in_copper:
803
+ if keyword_cat == "K0": # S1: No keyword
804
+ return self._create_result(
805
+ entry_number, description, primary_hts, additional_hts,
806
+ scenario_id="S1",
807
+ expected_codes=list(STEEL_232_CODES) + [GENERAL_301_CODE],
808
+ forbidden_codes={MISMATCH_CODE},
809
+ additional_set=additional_set,
810
+ **indicators
811
+ )
812
+ elif keyword_cat == "K1": # S2: Metal only
813
+ return self._create_result(
814
+ entry_number, description, primary_hts, additional_hts,
815
+ scenario_id="S2",
816
+ expected_codes=list(STEEL_232_CODES) + [GENERAL_301_CODE],
817
+ forbidden_codes={MISMATCH_CODE},
818
+ additional_set=additional_set,
819
+ **indicators
820
+ )
821
+ elif keyword_cat == "K2": # S3: Aluminum only
822
+ return self._create_result(
823
+ entry_number, description, primary_hts, additional_hts,
824
+ scenario_id="S3",
825
+ expected_codes=[MISMATCH_CODE],
826
+ forbidden_codes=STEEL_232_CODES | ALUMINUM_232_CODES | {GENERAL_301_CODE},
827
+ additional_set=additional_set,
828
+ **indicators
829
+ )
830
+ elif keyword_cat == "K3": # S4: Copper only
831
+ return self._create_result(
832
+ entry_number, description, primary_hts, additional_hts,
833
+ scenario_id="S4",
834
+ expected_codes=[MISMATCH_CODE],
835
+ forbidden_codes=STEEL_232_CODES | COPPER_CODES | {GENERAL_301_CODE},
836
+ additional_set=additional_set,
837
+ **indicators
838
+ )
839
+ elif keyword_cat == "K4": # S5: Metal + Aluminum
840
+ return self._create_result(
841
+ entry_number, description, primary_hts, additional_hts,
842
+ scenario_id="S5",
843
+ expected_codes=list(STEEL_232_CODES) + [GENERAL_301_CODE],
844
+ forbidden_codes={MISMATCH_CODE},
845
+ additional_set=additional_set,
846
+ **indicators
847
+ )
848
+ elif keyword_cat == "K5": # S6: Metal + Copper
849
+ return self._create_result(
850
+ entry_number, description, primary_hts, additional_hts,
851
+ scenario_id="S6",
852
+ expected_codes=list(STEEL_232_CODES) + [GENERAL_301_CODE],
853
+ forbidden_codes={MISMATCH_CODE},
854
+ additional_set=additional_set,
855
+ **indicators
856
+ )
857
+ elif keyword_cat == "K6": # S7: Aluminum + Copper
858
+ return self._create_result(
859
+ entry_number, description, primary_hts, additional_hts,
860
+ scenario_id="S7",
861
+ expected_codes=[MISMATCH_CODE],
862
+ forbidden_codes=ALL_232_CODES | {GENERAL_301_CODE},
863
+ additional_set=additional_set,
864
+ **indicators
865
+ )
866
+ elif keyword_cat == "K7": # S8: All three
867
+ return self._create_result(
868
+ entry_number, description, primary_hts, additional_hts,
869
+ scenario_id="S8",
870
+ expected_codes=list(STEEL_232_CODES) + [GENERAL_301_CODE],
871
+ forbidden_codes={MISMATCH_CODE},
872
+ additional_set=additional_set,
873
+ **indicators
874
+ )
875
 
876
+ # H2: Aluminum Only
877
  if in_aluminum and not in_steel and not in_copper:
878
+ if keyword_cat == "K0": # L1: No keyword
879
+ return self._create_result(
880
+ entry_number, description, primary_hts, additional_hts,
881
+ scenario_id="L1",
882
+ expected_codes=list(ALUMINUM_232_CODES) + [GENERAL_301_CODE],
883
+ forbidden_codes={MISMATCH_CODE},
884
+ additional_set=additional_set,
885
+ **indicators
886
+ )
887
+ elif keyword_cat == "K1": # L2: Metal only
888
+ return self._create_result(
889
+ entry_number, description, primary_hts, additional_hts,
890
+ scenario_id="L2",
891
+ expected_codes=[MISMATCH_CODE],
892
+ forbidden_codes=STEEL_232_CODES | ALUMINUM_232_CODES | {GENERAL_301_CODE},
893
+ additional_set=additional_set,
894
+ **indicators
895
+ )
896
+ elif keyword_cat == "K2": # L3: Aluminum only
897
+ return self._create_result(
898
+ entry_number, description, primary_hts, additional_hts,
899
+ scenario_id="L3",
900
+ expected_codes=list(ALUMINUM_232_CODES) + [GENERAL_301_CODE],
901
+ forbidden_codes={MISMATCH_CODE},
902
+ additional_set=additional_set,
903
+ **indicators
904
+ )
905
+ elif keyword_cat == "K3": # L4: Copper only
906
+ return self._create_result(
907
+ entry_number, description, primary_hts, additional_hts,
908
+ scenario_id="L4",
909
+ expected_codes=[MISMATCH_CODE],
910
+ forbidden_codes=ALUMINUM_232_CODES | COPPER_CODES | {GENERAL_301_CODE},
911
+ additional_set=additional_set,
912
+ **indicators
913
+ )
914
+ elif keyword_cat == "K4": # L5: Metal + Aluminum
915
+ return self._create_result(
916
+ entry_number, description, primary_hts, additional_hts,
917
+ scenario_id="L5",
918
+ expected_codes=list(ALUMINUM_232_CODES) + [GENERAL_301_CODE],
919
+ forbidden_codes={MISMATCH_CODE},
920
+ additional_set=additional_set,
921
+ **indicators
922
+ )
923
+ elif keyword_cat == "K5": # L6: Metal + Copper
924
+ return self._create_result(
925
+ entry_number, description, primary_hts, additional_hts,
926
+ scenario_id="L6",
927
+ expected_codes=[MISMATCH_CODE],
928
+ forbidden_codes=ALL_232_CODES | {GENERAL_301_CODE},
929
+ additional_set=additional_set,
930
+ **indicators
931
+ )
932
+ elif keyword_cat == "K6": # L7: Aluminum + Copper
933
+ return self._create_result(
934
+ entry_number, description, primary_hts, additional_hts,
935
+ scenario_id="L7",
936
+ expected_codes=list(ALUMINUM_232_CODES) + [GENERAL_301_CODE],
937
+ forbidden_codes={MISMATCH_CODE},
938
+ additional_set=additional_set,
939
+ **indicators
940
+ )
941
+ elif keyword_cat == "K7": # L8: All three
942
+ return self._create_result(
943
+ entry_number, description, primary_hts, additional_hts,
944
+ scenario_id="L8",
945
+ expected_codes=list(ALUMINUM_232_CODES) + [GENERAL_301_CODE],
946
+ forbidden_codes={MISMATCH_CODE},
947
+ additional_set=additional_set,
948
+ **indicators
949
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
950
 
951
+ # H3: Copper Only
952
  if in_copper and not in_steel and not in_aluminum:
953
+ if keyword_cat == "K0": # U1: No keyword
954
+ return self._create_result(
955
+ entry_number, description, primary_hts, additional_hts,
956
+ scenario_id="U1",
957
+ expected_codes=list(COPPER_CODES) + [GENERAL_301_CODE],
958
+ forbidden_codes={MISMATCH_CODE},
959
+ additional_set=additional_set,
960
+ **indicators
961
+ )
962
+ elif keyword_cat == "K1": # U2: Metal only
963
+ return self._create_result(
964
+ entry_number, description, primary_hts, additional_hts,
965
+ scenario_id="U2",
966
+ expected_codes=[MISMATCH_CODE],
967
+ forbidden_codes=STEEL_232_CODES | COPPER_CODES | {GENERAL_301_CODE},
968
+ additional_set=additional_set,
969
+ **indicators
970
+ )
971
+ elif keyword_cat == "K2": # U3: Aluminum only
972
+ return self._create_result(
973
+ entry_number, description, primary_hts, additional_hts,
974
+ scenario_id="U3",
975
+ expected_codes=[MISMATCH_CODE],
976
+ forbidden_codes=ALUMINUM_232_CODES | COPPER_CODES | {GENERAL_301_CODE},
977
+ additional_set=additional_set,
978
+ **indicators
979
+ )
980
+ elif keyword_cat == "K3": # U4: Copper only
981
+ return self._create_result(
982
+ entry_number, description, primary_hts, additional_hts,
983
+ scenario_id="U4",
984
+ expected_codes=list(COPPER_CODES),
985
+ forbidden_codes={MISMATCH_CODE},
986
+ additional_set=additional_set,
987
+ **indicators
988
+ )
989
+ elif keyword_cat == "K4": # U5: Metal + Aluminum
990
+ return self._create_result(
991
+ entry_number, description, primary_hts, additional_hts,
992
+ scenario_id="U5",
993
+ expected_codes=[MISMATCH_CODE],
994
+ forbidden_codes=ALL_232_CODES | {GENERAL_301_CODE},
995
+ additional_set=additional_set,
996
+ **indicators
997
+ )
998
+ elif keyword_cat == "K5": # U6: Metal + Copper
999
+ return self._create_result(
1000
+ entry_number, description, primary_hts, additional_hts,
1001
+ scenario_id="U6",
1002
+ expected_codes=list(COPPER_CODES),
1003
+ forbidden_codes={MISMATCH_CODE},
1004
+ additional_set=additional_set,
1005
+ **indicators
1006
+ )
1007
+ elif keyword_cat == "K6": # U7: Aluminum + Copper
1008
+ return self._create_result(
1009
+ entry_number, description, primary_hts, additional_hts,
1010
+ scenario_id="U7",
1011
+ expected_codes=list(COPPER_CODES),
1012
+ forbidden_codes={MISMATCH_CODE},
1013
+ additional_set=additional_set,
1014
+ **indicators
1015
+ )
1016
+ elif keyword_cat == "K7": # U8: All three
1017
+ return self._create_result(
1018
+ entry_number, description, primary_hts, additional_hts,
1019
+ scenario_id="U8",
1020
+ expected_codes=list(COPPER_CODES),
1021
+ forbidden_codes={MISMATCH_CODE},
1022
+ additional_set=additional_set,
1023
+ **indicators
1024
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1025
 
1026
+ # =====================================================================
1027
+ # LEVEL 4: No HTS Match
1028
+ # =====================================================================
1029
+
1030
+ # H0: Not in any metal list
1031
+ if keyword_cat == "K0": # N1: No keyword
1032
+ return self._create_result(
1033
+ entry_number, description, primary_hts, additional_hts,
1034
+ scenario_id="N1",
1035
+ expected_codes=[],
1036
+ forbidden_codes=set(),
1037
+ additional_set=additional_set,
1038
+ **indicators
1039
+ )
1040
+ elif keyword_cat == "K1": # N2: Metal only
1041
+ return self._create_result(
1042
+ entry_number, description, primary_hts, additional_hts,
1043
+ scenario_id="N2",
1044
+ expected_codes=[MISMATCH_CODE],
1045
+ forbidden_codes=STEEL_232_CODES,
1046
+ additional_set=additional_set,
1047
+ **indicators
1048
+ )
1049
+ elif keyword_cat == "K2": # N3: Aluminum only
1050
+ return self._create_result(
1051
+ entry_number, description, primary_hts, additional_hts,
1052
+ scenario_id="N3",
1053
+ expected_codes=[MISMATCH_CODE],
1054
+ forbidden_codes=ALUMINUM_232_CODES,
1055
+ additional_set=additional_set,
1056
+ **indicators
1057
+ )
1058
+ elif keyword_cat == "K3": # N4: Copper only
1059
+ return self._create_result(
1060
+ entry_number, description, primary_hts, additional_hts,
1061
+ scenario_id="N4",
1062
+ expected_codes=[MISMATCH_CODE],
1063
+ forbidden_codes=COPPER_CODES,
1064
+ additional_set=additional_set,
1065
+ **indicators
1066
+ )
1067
+ elif keyword_cat == "K4": # N5: Metal + Aluminum
1068
+ return self._create_result(
1069
+ entry_number, description, primary_hts, additional_hts,
1070
+ scenario_id="N5",
1071
+ expected_codes=[MISMATCH_CODE],
1072
+ forbidden_codes=STEEL_232_CODES | ALUMINUM_232_CODES,
1073
+ additional_set=additional_set,
1074
+ **indicators
1075
+ )
1076
+ elif keyword_cat == "K5": # N6: Metal + Copper
1077
+ return self._create_result(
1078
+ entry_number, description, primary_hts, additional_hts,
1079
+ scenario_id="N6",
1080
+ expected_codes=[MISMATCH_CODE],
1081
+ forbidden_codes=STEEL_232_CODES | COPPER_CODES,
1082
+ additional_set=additional_set,
1083
+ **indicators
1084
+ )
1085
+ elif keyword_cat == "K6": # N7: Aluminum + Copper
1086
+ return self._create_result(
1087
+ entry_number, description, primary_hts, additional_hts,
1088
+ scenario_id="N7",
1089
+ expected_codes=[MISMATCH_CODE],
1090
+ forbidden_codes=ALUMINUM_232_CODES | COPPER_CODES,
1091
+ additional_set=additional_set,
1092
+ **indicators
1093
+ )
1094
+ elif keyword_cat == "K7": # N8: All three
1095
+ return self._create_result(
1096
+ entry_number, description, primary_hts, additional_hts,
1097
+ scenario_id="N8",
1098
+ expected_codes=[MISMATCH_CODE],
1099
+ forbidden_codes=ALL_232_CODES,
1100
+ additional_set=additional_set,
1101
+ **indicators
1102
  )
1103
 
1104
+ # Fallback - should not reach here
1105
  return ValidationResult(
1106
  entry_number=entry_number,
1107
  description=description,
 
1113
  expected_hts=[],
1114
  missing_hts=[],
1115
  unexpected_hts=[],
1116
+ issue="No validation rule matched",
1117
+ in_steel_hts=in_steel,
1118
+ in_aluminum_hts=in_aluminum,
1119
+ in_copper_hts=in_copper,
1120
+ in_computer_hts=in_computer_parts,
1121
+ in_auto_hts=in_auto_parts,
1122
+ has_metal_keyword=has_metal_kw,
1123
+ has_aluminum_keyword=has_aluminum_kw,
1124
+ has_copper_keyword=has_copper_kw,
1125
+ has_zinc_keyword=has_zinc_kw,
1126
+ has_plastics_keyword=has_plastics_kw
1127
  )
1128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1129
 
1130
  def validate_dataframe(df, validator: HTSValidator,
1131
  description_col: str = "Description",