diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 30327d7..01eabe8 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,7 +14,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - php: [8.1, 8.2, 8.3] + php: [8.1, 8.2, 8.3, 8.4, 8.5] name: PHP ${{ matrix.php }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..afc906f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,364 @@ +# AGENTS.md - AI Agent Context for Good Code Parser + +This file provides essential context for AI agents (like Claude, Cursor, GitHub Copilot) working with this codebase. + +## Project Overview + +**Name:** Good Code Parser +**Purpose:** PHP library for parsing and standardizing product codes from various e-commerce platforms (Coupang, 11th Street, Naver Storefarm, etc.) for WMS (Warehouse Management System) integration +**PHP Version:** 8.1+ +**License:** MIT +**Package:** `cable8mm/good-code` + +## Architecture + +### Design Patterns + +- **Value Object Pattern**: `SetGood`, `LocationCode`, `ReceiptCode`, `Sku` are immutable value objects +- **Factory Method Pattern**: Static `of()` methods for instance creation +- **Enum Pattern**: `GoodCodeType` enum for type safety +- **Strategy Pattern**: Callback functions for code transformation + +### Core Components + +#### 1. GoodCode (Main Class) + +**Location:** `src/GoodCode.php` +**Purpose:** Central parser for all good code types + +**Key Concepts:** + +- `originType`: Original code type before transformation (OPTION, GIFT, COMPLEX) +- `type`: Current code type after transformation (NORMAL, SET, etc.) +- `code()`: Returns the final transformed code +- `value()`: Returns parsed value (array for SET, string for NORMAL) +- Throws `BadFunctionCallException` for OPTION/GIFT/COMPLEX types (must use callback first) + +**Usage Pattern:** + +```php +// Normal code - direct use +$code = GoodCode::of('72363')->value(); // '72363' + +// Set code - returns array +$code = GoodCode::of('SET1234x2zz5678x1')->value(); // ['1234' => 2, '5678' => 1] + +// Complex/Gift/Option - requires callback +$code = GoodCode::of('COM10', callback: fn($key) => $map[$key])->value(); +``` + +#### 2. GoodCodeType (Enum) + +**Location:** `src/Enums/GoodCodeType.php` +**Values:** + +- `NORMAL` - Single product code (no prefix) +- `SET` - Multiple products (prefix: SET) +- `COMPLEX` - Alias for SET (prefix: COM) +- `GIFT` - Alias for COMPLEX (prefix: GIF) +- `OPTION` - Product with options (prefix: OPT) + +**Detection Logic:** Checks first 3 characters (case-insensitive) + +#### 3. SetGood (Value Object) + +**Location:** `src/ValueObjects/SetGood.php` +**Format:** `SET{goodCode}x{count}zz{goodCode}x{count}` +**Example:** `SET7369x4zz4235x6` → `['7369' => 4, '4235' => 6]` + +**Important:** + +- Delimiter between products: `zz` (case-sensitive) +- Delimiter between code and count: `x` +- Constructor is private - use `of()` or `ofArray()` +- Throws `InvalidArgumentException` for invalid formats + +#### 4. ReceiptCode (Value Object) + +**Location:** `src/ReceiptCode.php` +**Format:** `{prefix}-{Ymd}-{number}` +**Example:** `PO-20250312-0001` + +**Key Features:** + +- Auto-increment with `nextCode()` +- Resets to `0001` when date changes +- Default prefix: `PO` +- Public properties: `$prefix`, `$ymd`, `$number`, `$code` + +#### 5. LocationCode (Value Object) + +**Location:** `src/LocationCode.php` +**Format:** `{warehouse}-{rack}-{shelf}` +**Example:** `AUK-R3-S32` + +**Key Features:** + +- All components are optional (except at least one required) +- Implements `Stringable` for easy casting +- Filters out null/empty values automatically +- Prevents duplicate dashes + +#### 6. Sku (Value Object) + +**Location:** `src/Sku.php` +**Format:** `{prefix}{code}` +**Example:** `PO123`, `123` + +**Key Features:** + +- Simplest structure +- Prefix is optional +- Implements `Stringable` + +## Testing Strategy + +### Test Structure + +``` +tests/ +├── GoodCodeTest.php # Main parser tests +├── LocationCodeTest.php # Location code tests +├── ReceiptCodeTest.php # Receipt code tests +├── SkuTest.php # SKU tests +├── Enums/ +│ └── GoodCodeTypeTest.php # Enum tests +└── ValueObjects/ + └── SetGoodTest.php # SetGood tests +``` + +### Test Count + +- **Total:** 45 tests, 65 assertions +- All tests use PHPUnit 11.x +- Follow AAA pattern (Arrange-Act-Assert) + +### Running Tests + +```bash +composer test # Run all tests +composer test --filter=GoodCodeTest # Run specific test +``` + +## Code Conventions + +### Naming + +- Classes: PascalCase (`GoodCode`, `SetGood`) +- Methods: camelCase (`of()`, `nextCode()`) +- Constants: UPPER_SNAKE_CASE (`DELIMITER`, `DELIMITER_COUNT`) +- Private properties: camelCase with type hints + +### Immutability + +- All value objects use `readonly` properties where possible +- Constructor is private - use factory methods +- No setters - create new instances for changes + +### Error Handling + +- Use `InvalidArgumentException` for invalid input +- Use `BadFunctionCallException` for unsupported operations +- Always validate input in `of()` methods before construction + +### Type Safety + +- Strict type declarations on all methods +- Return types always specified +- Use PHP 8.1+ features (enum, readonly, match) + +## Common Pitfalls + +### 1. GoodCode::value() Limitations + +```php +// ❌ WRONG - Will throw exception +$code = GoodCode::of('COM10', callback: fn($k) => '123'); +$value = $code->value(); // BadFunctionCallException + +// ✅ CORRECT - Callback transforms to NORMAL first +$code = GoodCode::of('COM10', callback: fn($k) => '123'); +$value = $code->value(); // '123' (type is now NORMAL) +``` + +### 2. SetGood Delimiters + +- `zz` is case-sensitive (must be lowercase) +- `x` separates code from count +- Empty payload after SET prefix removal throws exception + +### 3. ReceiptCode Date Logic + +- `nextCode()` compares with today's date, not stored date +- If dates differ, resets to `0001` with today's date +- Number is stored as string with leading zeros + +### 4. LocationCode Empty Values + +- Empty strings are filtered out +- `LocationCode::of(warehouse: 'AUK', rack: '')` → `'AUK'` +- At least one non-empty value required + +## Extension Points + +### Adding New Code Types + +1. Add new case to `GoodCodeType` enum +2. Add prefix mapping in `prefix()` method +3. Add detection logic in `of()` method +4. Add transformation logic in `GoodCode::of()` if needed +5. Add tests in `tests/Enums/GoodCodeTypeTest.php` + +### Adding New Value Objects + +1. Create class in `src/` or `src/ValueObjects/` +2. Implement `Stringable` if string representation needed +3. Use private constructor with static factory methods +4. Add comprehensive edge case tests +5. Document in README.md + +## Important Notes for AI Agents + +### When Modifying Code + +1. **Always run tests:** `composer test` after changes +2. **Check edge cases:** Empty strings, null values, invalid formats +3. **Preserve immutability:** Don't add setters to value objects +4. **Update documentation:** Keep README.md in sync with code changes +5. **Add tests:** Every new feature needs tests + +### When Creating Tests + +1. Follow AAA pattern +2. Test both success and failure cases +3. Test edge cases (empty, null, invalid) +4. Use descriptive test names +5. Add PHPDoc with examples + +### Code Style + +- Follow PSR-12 standard +- Use Laravel Pint for formatting: `composer lint` +- Maximum line length: 120 characters +- Use strict comparisons (`===`, `!==`) + +## Recent Changes + +### 2025-02-04 + +- Initial release with GoodCode, GoodCodeType, SetGood +- Support for NORMAL, SET, COMPLEX, GIFT, OPTION codes + +### 2025-02-21 + +- Added ReceiptCode for purchase order codes +- Auto-increment functionality with date reset + +### 2025-02-24 + +- Added LocationCode for warehouse locations +- Added Sku for product SKUs +- Implemented Stringable interface on all value objects + +### 2025-02-04 (Latest) + +- Fixed ReceiptCode::nextCode() date comparison bug +- Enhanced SetGood::pipe() with validation +- Added comprehensive edge case tests (45 tests total) +- Improved error messages +- Fixed README.md documentation errors + +## Dependencies + +### Production + +- PHP 8.1+ + +### Development + +- PHPUnit 9.0|10.0|11.0 +- Laravel Pint 1.0 (code style) + +## Resources + +- **API Documentation:** https://www.palgle.com/good-code/ +- **Laravel Integration:** https://github.com/cable8mm/aipro +- **Packagist:** https://packagist.org/packages/cable8mm/good-code +- **GitHub:** https://github.com/cable8mm/good-code + +## Quick Reference + +### Creating Instances + +```php +// GoodCode +GoodCode::of('72363'); // NORMAL +GoodCode::of('SET1234x2zz5678x1'); // SET +GoodCode::of('COM10', callback: fn($k) => $map[$k]); // COMPLEX +GoodCode::of('GIF11', callback: fn($k) => $map[$k]); // GIFT +GoodCode::of('OPT10', option: 'Name', callback: fn($k, $o) => $map[$k][$o]); // OPTION +GoodCode::setCodeOf(['1234' => 2, '5678' => 1]); // SET from array + +// SetGood +SetGood::of('SET1234x2zz5678x1'); +SetGood::ofArray(['1234' => 2, '5678' => 1]); + +// ReceiptCode +ReceiptCode::of('PO-20250312-0001'); +ReceiptCode::of(prefix: 'CT'); // New with custom prefix + +// LocationCode +LocationCode::of(warehouse: 'AUK', rack: 'R3', shelf: 'S32'); +LocationCode::of(['warehouse' => 'AUK', 'rack' => 'R3']); + +// Sku +Sku::of(123, prefix: 'PO'); +Sku::of(['code' => 123, 'prefix' => 'PO']); +``` + +### Common Operations + +```php +// Get values +$code->value(); // Parsed value +$code->code(); // Raw code string +$code->type(); // Current type +$code->originalType(); // Original type before transformation + +// ReceiptCode operations +$receipt->nextCode(); // Get next sequential code +$receipt->prefix; // 'PO' +$receipt->ymd; // '20250312' +$receipt->number; // '0001' + +// String conversion +(string) $locationCode; // 'AUK-R3-S32' +(string) $receiptCode; // 'PO-20250312-0001' +(string) $sku; // 'PO123' +``` + +## AI Agent Guidelines + +### DO: + +✅ Run tests after every change +✅ Add edge case tests for new features +✅ Preserve backward compatibility +✅ Update README.md with code changes +✅ Use type hints and return types +✅ Follow existing code patterns + +### DON'T: + +❌ Modify test files to make failing tests pass (fix the code instead) +❌ Remove existing tests without discussion +❌ Change public API without updating documentation +❌ Add dependencies without discussion +❌ Break immutability of value objects +❌ Use loose comparisons (`==`, `!=`) + +## Contact + +**Author:** Sam Lee +**GitHub:** https://github.com/cable8mm +**Website:** https://www.palgle.com diff --git a/README.md b/README.md index 1652402..4da79a0 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ print GoodCode::of($optionCode, option: $optionName, callback: function ($key, $ 'The Legend of Zelda: Tears of the Kingdom' => '1234', 'Call of Duty®: Black Ops 6' => '2314', 'Grand Theft Auto V' => '43123', - '42342', 'name' => 'Marvel\'s Spider-Man 2', + '42342' => 'Marvel\'s Spider-Man 2', ], ]; @@ -208,8 +208,8 @@ print ReceiptCode::of(prefix: 'CT')->nextCode(); ```php print LocationCode::of(warehouse: 'AUK', rack: 'R3', shelf: 'S32')->locationCode(); -print LocationCode::of(['warehouse' => 'AUK', 'rack' => 'R3', 'shelf' => 'S32')->locationCode(); -print LocationCode::of(warehouse: 'AUK', rack: 'R3', shelf: 'S32'); //` Stringable` supported +print LocationCode::of(['warehouse' => 'AUK', 'rack' => 'R3', 'shelf' => 'S32'])->locationCode(); +print (string) LocationCode::of(warehouse: 'AUK', rack: 'R3', shelf: 'S32'); // Stringable supported //=> AUK-R3-S32 ``` @@ -245,4 +245,4 @@ composer test ## License -The Phpunit Start Kit is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +The Good Code Parser is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/src/GoodCode.php b/src/GoodCode.php index d5920c5..57dee2e 100644 --- a/src/GoodCode.php +++ b/src/GoodCode.php @@ -24,7 +24,7 @@ class GoodCode * Constructor. * * @param string $code The code - * @param \Cable8mm\GoodCode\Enums\GoodCodeType $originType The type of the code + * @param GoodCodeType $originType The type of the code */ public function __construct( private string $code, @@ -61,14 +61,14 @@ public function type(): GoodCodeType * Output value for good code. * If the code is set code, it will be array of good values. * If the code is normal code, it will be good code string. - * the code shouldn't be option, complex and gift code. + * This method does not support OPTION, GIFT, and COMPLEX code types. * * @throws BadFunctionCallException */ public function value(): int|string|array { if ($this->type == GoodCodeType::OPTION || $this->type == GoodCodeType::GIFT || $this->type == GoodCodeType::COMPLEX) { - return throw new BadFunctionCallException('Only complex and set code types are supported'); + return throw new BadFunctionCallException('OPTION, GIFT, and COMPLEX code types are not supported for value() method. Use callback to transform these codes first.'); } if ($this->type == GoodCodeType::SET) { diff --git a/src/ReceiptCode.php b/src/ReceiptCode.php index e4a905d..edba851 100644 --- a/src/ReceiptCode.php +++ b/src/ReceiptCode.php @@ -51,10 +51,12 @@ private function __construct( * * @param string $code ReceiptCode * @return static Provides a fluent interface + * + * @throws InvalidArgumentException */ public function code(string $code): static { - if (preg_match('/^([^\-]+)\-([^\-]+)\-(.+)$/', $code, $matches) === false) { + if (preg_match('/^([^\-]+)\-([^\-]+)\-(.+)$/', $code, $matches) !== 1) { throw new InvalidArgumentException('Invalid code format.'); } @@ -79,11 +81,13 @@ public function nextCode(): string return $this->prefix.'-'.date('Ymd').'-0001'; } - if ($this->ymd === $this->ymd) { + $today = date('Ymd'); + + if ($this->ymd === $today) { return $this->prefix.'-'.$this->ymd.'-'.str_pad($this->number + 1, 4, '0', STR_PAD_LEFT); } - return $this->prefix.'-'.date('Ymd').'-0001'; + return $this->prefix.'-'.$today.'-0001'; } /** diff --git a/src/Sku.php b/src/Sku.php index 570a091..4585dd0 100644 --- a/src/Sku.php +++ b/src/Sku.php @@ -86,7 +86,7 @@ public static function of( * * @return string The magic method returns the SKU * - * @example print Sku::of('1') => 'A1' + * @example print Sku::of('1') => '1' * @example print Sku::of('1', prefix: 'BO') => 'BO1' */ public function __toString(): string diff --git a/src/ValueObjects/SetGood.php b/src/ValueObjects/SetGood.php index 70639fa..c0a8074 100644 --- a/src/ValueObjects/SetGood.php +++ b/src/ValueObjects/SetGood.php @@ -45,13 +45,25 @@ private function __construct(private readonly string $code) * * @param string $setCode "set1232x3ZZ322ZZ4313x4" means "1232" x 3 + "322" x 4 + "4313" x 4. "1232", "322" and "4312" are good codes. * @return array The method returns good code array + * + * @throws InvalidArgumentException */ private function pipe(): void { $payload = preg_replace('/^'.GoodCodeType::SET->prefix().'/i', '', $this->code); + if (empty($payload)) { + throw new InvalidArgumentException('It is not valid code'); + } + foreach (explode(SetGood::DELIMITER, $payload) as $good) { - [$k, $v] = explode(SetGood::DELIMITER_COUNT, $good); + $parts = explode(SetGood::DELIMITER_COUNT, $good); + + if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) { + throw new InvalidArgumentException('It is not valid code'); + } + + [$k, $v] = $parts; $this->goods[$k] = $v; } } @@ -77,12 +89,12 @@ public static function of(string $code): SetGood * @param array $setCodes key-value set code array * @return SetGood The method returns SetGood instance with the SetCode string * - * @example SetGood::ofArray(['7369'=>4,'4235'=>6]) => SET7369x4zz42335x6 + * @example SetGood::ofArray(['7369'=>4,'4235'=>6]) => SET7369x4zz4235x6 */ public static function ofArray(array $setCodes): SetGood { - $code = GoodCodeType::SET->prefix().implode(SetGood::DELIMITER, array_map(function ($v, $k) { - return $k.SetGood::DELIMITER_COUNT.$v; + $code = GoodCodeType::SET->prefix().implode(SetGood::DELIMITER, array_map(function ($count, $goodCode) { + return $goodCode.SetGood::DELIMITER_COUNT.$count; }, $setCodes, array_keys($setCodes))); return static::of($code); diff --git a/tests/GoodCodeTest.php b/tests/GoodCodeTest.php index d3bd1e5..d4fc252 100644 --- a/tests/GoodCodeTest.php +++ b/tests/GoodCodeTest.php @@ -2,6 +2,7 @@ namespace Cable8mm\GoodCode\Tests; +use Cable8mm\GoodCode\Enums\GoodCodeType; use Cable8mm\GoodCode\GoodCode; use PHPUnit\Framework\TestCase; @@ -98,4 +99,26 @@ public function test_option_code() // Assert $this->assertEquals('3124', $code->value()); } + + public function test_normal_code_value() + { + $code = GoodCode::of('72363'); + $this->assertEquals('72363', $code->value()); + } + + public function test_get_id_from_gift_code() + { + $this->assertEquals(11, GoodCode::getId('GIF11')); + } + + public function test_get_id_from_complex_code() + { + $this->assertEquals(10, GoodCode::getId('COM10')); + } + + public function test_original_type_preserved() + { + $code = GoodCode::of('SET1234x2zz5678x1'); + $this->assertEquals(GoodCodeType::SET, $code->originalType()); + } } diff --git a/tests/LocationCodeTest.php b/tests/LocationCodeTest.php index d8baaef..bf8ae42 100644 --- a/tests/LocationCodeTest.php +++ b/tests/LocationCodeTest.php @@ -98,4 +98,31 @@ public function test_location_code_to_string() 'shelf' => 'S32', ])); } + + public function test_location_code_with_empty_string() + { + $this->assertEquals('AUK', LocationCode::of( + warehouse: 'AUK', + rack: '', + shelf: '' + )->locationCode()); + } + + public function test_location_code_with_special_characters() + { + $this->assertEquals('AUK-R3-S32', LocationCode::of( + warehouse: 'AUK', + rack: 'R3', + shelf: 'S32' + )->locationCode()); + } + + public function test_location_code_no_duplicate_dashes() + { + $this->assertEquals('AUK-R3', LocationCode::of( + warehouse: 'AUK', + rack: '', + shelf: 'R3' + )->locationCode()); + } } diff --git a/tests/ReceiptCodeTest.php b/tests/ReceiptCodeTest.php index 6892bf5..0fc58a4 100644 --- a/tests/ReceiptCodeTest.php +++ b/tests/ReceiptCodeTest.php @@ -22,8 +22,9 @@ public function test_code_method() public function test_next_code_method() { - $this->assertEquals('PO-20250312-0002', ReceiptCode::of('PO-20250312-0001')->nextCode()); - $this->assertEquals('PO-20250312-10000', ReceiptCode::of('PO-20250312-9999')->nextCode()); + $today = date('Ymd'); + $this->assertEquals('PO-'.$today.'-0002', ReceiptCode::of('PO-'.$today.'-0001')->nextCode()); + $this->assertEquals('PO-'.$today.'-10000', ReceiptCode::of('PO-'.$today.'-9999')->nextCode()); } public function test_no_code() @@ -35,4 +36,67 @@ public function test_prefix() { $this->assertEquals('CT', ReceiptCode::of(prefix: 'CT')->prefix); } + + public function test_next_code_with_different_date() + { + // This test verifies that nextCode() resets to 0001 when date changes + // We can't easily test actual date changes, but we can test the logic + $receiptCode = ReceiptCode::of('PO-20250101-0001'); + + // If today is not 20250101, it should return today's date with 0001 + $nextCode = $receiptCode->nextCode(); + $today = date('Ymd'); + + // Verify the format is correct + $this->assertMatchesRegularExpression('/^PO-'.$today.'-0001$/', $nextCode); + } + + public function test_invalid_code_format() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid code format.'); + + ReceiptCode::of('INVALID_CODE'); + } + + public function test_code_with_single_dash() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid code format.'); + + ReceiptCode::of('PO-20250312'); + } + + public function test_receipt_code_with_zero_number() + { + $receiptCode = ReceiptCode::of('PO-20250312-0000'); + + $this->assertEquals('PO-20250312-0000', $receiptCode->code); + $this->assertEquals('0000', $receiptCode->number); + } + + public function test_receipt_code_next_with_zero() + { + $today = date('Ymd'); + $this->assertEquals('PO-'.$today.'-0001', ReceiptCode::of('PO-'.$today.'-0000')->nextCode()); + } + + public function test_receipt_code_with_special_prefix() + { + $receiptCode = ReceiptCode::of('IN-20250312-0001'); + + $this->assertEquals('IN', $receiptCode->prefix); + $this->assertEquals('20250312', $receiptCode->ymd); + $this->assertEquals('0001', $receiptCode->number); + } + + public function test_receipt_code_to_string() + { + $this->assertEquals('PO-20250312-0001', (string) ReceiptCode::of('PO-20250312-0001')); + } + + public function test_receipt_code_to_string_empty() + { + $this->assertEquals('', (string) ReceiptCode::of()); + } } diff --git a/tests/ValueObjects/SetGoodTest.php b/tests/ValueObjects/SetGoodTest.php index 00f5cd6..c5b9cbf 100644 --- a/tests/ValueObjects/SetGoodTest.php +++ b/tests/ValueObjects/SetGoodTest.php @@ -28,4 +28,43 @@ public function test_fire_exception() SetGood::of('sdf23brew'); } + + public function test_set_good_with_missing_count() + { + $this->expectException(\InvalidArgumentException::class); + + SetGood::of('SET1234x'); + } + + public function test_set_good_with_missing_good_code() + { + $this->expectException(\InvalidArgumentException::class); + + SetGood::of('SETx3'); + } + + public function test_set_good_with_invalid_format() + { + $this->expectException(\InvalidArgumentException::class); + + SetGood::of('SET1234x3x5'); + } + + public function test_set_good_with_empty_string() + { + $this->expectException(\InvalidArgumentException::class); + + SetGood::of(''); + } + + public function test_set_good_with_multiple_delimiters() + { + $setGood = SetGood::of('SET1234x3zz5678x2zz9012x1'); + + $this->assertEquals([ + '1234' => 3, + '5678' => 2, + '9012' => 1, + ], $setGood->goods()); + } }