This commit is contained in:
Joelson
2025-09-17 18:49:23 -03:00
parent 21c3225c52
commit e081df9ced
42 changed files with 4129 additions and 411 deletions

233
package-lock.json generated
View File

@@ -745,6 +745,16 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
} }
}, },
"node_modules/@inquirer/ansi": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz",
"integrity": "sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@inquirer/checkbox": { "node_modules/@inquirer/checkbox": {
"version": "4.1.6", "version": "4.1.6",
"resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.6.tgz",
@@ -793,15 +803,15 @@
} }
}, },
"node_modules/@inquirer/core": { "node_modules/@inquirer/core": {
"version": "10.1.11", "version": "10.2.2",
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.11.tgz", "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.2.tgz",
"integrity": "sha512-BXwI/MCqdtAhzNQlBEFE7CEflhPkl/BqvAuV/aK6lW3DClIfYVDWPP/kXuXHtBWC7/EEbNqd/1BGq2BGBBnuxw==", "integrity": "sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@inquirer/figures": "^1.0.11", "@inquirer/ansi": "^1.0.0",
"@inquirer/type": "^3.0.6", "@inquirer/figures": "^1.0.13",
"ansi-escapes": "^4.3.2", "@inquirer/type": "^3.0.8",
"cli-width": "^4.1.0", "cli-width": "^4.1.0",
"mute-stream": "^2.0.0", "mute-stream": "^2.0.0",
"signal-exit": "^4.1.0", "signal-exit": "^4.1.0",
@@ -821,15 +831,15 @@
} }
}, },
"node_modules/@inquirer/editor": { "node_modules/@inquirer/editor": {
"version": "4.2.11", "version": "4.2.20",
"resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.11.tgz", "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.20.tgz",
"integrity": "sha512-YoZr0lBnnLFPpfPSNsQ8IZyKxU47zPyVi9NLjCWtna52//M/xuL0PGPAxHxxYhdOhnvY2oBafoM+BI5w/JK7jw==", "integrity": "sha512-7omh5y5bK672Q+Brk4HBbnHNowOZwrb/78IFXdrEB9PfdxL3GudQyDk8O9vQ188wj3xrEebS2M9n18BjJoI83g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@inquirer/core": "^10.1.11", "@inquirer/core": "^10.2.2",
"@inquirer/type": "^3.0.6", "@inquirer/external-editor": "^1.0.2",
"external-editor": "^3.1.0" "@inquirer/type": "^3.0.8"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -866,10 +876,49 @@
} }
} }
}, },
"node_modules/@inquirer/external-editor": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz",
"integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chardet": "^2.1.0",
"iconv-lite": "^0.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/external-editor/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@inquirer/figures": { "node_modules/@inquirer/figures": {
"version": "1.0.11", "version": "1.0.13",
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz",
"integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1046,9 +1095,9 @@
} }
}, },
"node_modules/@inquirer/type": { "node_modules/@inquirer/type": {
"version": "3.0.6", "version": "3.0.8",
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.6.tgz", "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz",
"integrity": "sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==", "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3485,13 +3534,13 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.9.0", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -3822,9 +3871,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4130,9 +4179,9 @@
} }
}, },
"node_modules/chardet": { "node_modules/chardet": {
"version": "0.7.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -5480,34 +5529,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/external-editor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
"integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
"dev": true,
"license": "MIT",
"dependencies": {
"chardet": "^0.7.0",
"iconv-lite": "^0.4.24",
"tmp": "^0.0.33"
},
"engines": {
"node": ">=4"
}
},
"node_modules/external-editor/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/extglob": { "node_modules/extglob": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
@@ -5776,14 +5797,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.2", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12" "mime-types": "^2.1.12"
}, },
"engines": { "engines": {
@@ -6051,9 +6073,9 @@
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7613,15 +7635,16 @@
} }
}, },
"node_modules/jsdom/node_modules/form-data": { "node_modules/jsdom/node_modules/form-data": {
"version": "3.0.3", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.3.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz",
"integrity": "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==", "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.35" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
@@ -8687,16 +8710,6 @@
"node": ">=14.6" "node": ">=14.6"
} }
}, },
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/p-each-series": { "node_modules/p-each-series": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz",
@@ -10312,16 +10325,23 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/sha.js": { "node_modules/sha.js": {
"version": "2.4.11", "version": "2.4.12",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz",
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
"license": "(MIT AND BSD-3-Clause)", "license": "(MIT AND BSD-3-Clause)",
"dependencies": { "dependencies": {
"inherits": "^2.0.1", "inherits": "^2.0.4",
"safe-buffer": "^5.0.1" "safe-buffer": "^5.2.1",
"to-buffer": "^1.2.0"
}, },
"bin": { "bin": {
"sha.js": "bin.js" "sha.js": "bin.js"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/shebang-command": { "node_modules/shebang-command": {
@@ -11347,19 +11367,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/tmpl": { "node_modules/tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -11367,6 +11374,26 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/to-buffer": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz",
"integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==",
"license": "MIT",
"dependencies": {
"isarray": "^2.0.5",
"safe-buffer": "^5.2.1",
"typed-array-buffer": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/to-buffer/node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"license": "MIT"
},
"node_modules/to-object-path": { "node_modules/to-object-path": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
@@ -11767,6 +11794,20 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
"integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
"es-errors": "^1.3.0",
"is-typed-array": "^1.1.14"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/typedarray": { "node_modules/typedarray": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
@@ -11890,9 +11931,9 @@
} }
}, },
"node_modules/typeorm/node_modules/brace-expansion": { "node_modules/typeorm/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"

View File

@@ -23,6 +23,8 @@ import { ThrottlerModule, ThrottlerModuleOptions } from '@nestjs/throttler';
import { RateLimiterMiddleware } from './common/middlewares/rate-limiter.middleware'; import { RateLimiterMiddleware } from './common/middlewares/rate-limiter.middleware';
import { RequestSanitizerMiddleware } from './common/middlewares/request-sanitizer.middleware'; import { RequestSanitizerMiddleware } from './common/middlewares/request-sanitizer.middleware';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { clientes } from './data-consult/clientes.module';
import { PartnersModule } from './partners/partners.module';
@Module({ @Module({
@@ -57,6 +59,7 @@ import { HealthModule } from './health/health.module';
OrdersPaymentModule, OrdersPaymentModule,
HttpModule, HttpModule,
OrdersModule, OrdersModule,
clientes,
ProductsModule, ProductsModule,
NegotiationsModule, NegotiationsModule,
OccurrencesModule, OccurrencesModule,
@@ -66,6 +69,7 @@ import { HealthModule } from './health/health.module';
AuthModule, AuthModule,
OrdersModule, OrdersModule,
HealthModule, HealthModule,
PartnersModule,
], ],
controllers: [OcorrencesController, LogisticController ], controllers: [OcorrencesController, LogisticController ],
providers: [ LogisticService,], providers: [ LogisticService,],

View File

@@ -9,6 +9,7 @@ import {
UseGuards, UseGuards,
Request, Request,
Param, Param,
Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs'; import { CommandBus } from '@nestjs/cqrs';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
@@ -16,6 +17,7 @@ import { AuthenticateUserCommand } from './commands/authenticate-user.command';
import { LoginResponseDto } from './dto/LoginResponseDto'; import { LoginResponseDto } from './dto/LoginResponseDto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { ResultModel } from 'src/core/models/result.model'; import { ResultModel } from 'src/core/models/result.model';
import { DateUtil } from 'src/shared/date.util';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RateLimitingGuard } from '../guards/rate-limiting.guard'; import { RateLimitingGuard } from '../guards/rate-limiting.guard';
@@ -24,6 +26,13 @@ import { RefreshTokenService } from '../services/refresh-token.service';
import { SessionManagementService } from '../services/session-management.service'; import { SessionManagementService } from '../services/session-management.service';
import { RefreshTokenDto, RefreshTokenResponseDto } from './dto/refresh-token.dto'; import { RefreshTokenDto, RefreshTokenResponseDto } from './dto/refresh-token.dto';
import { SessionsResponseDto } from './dto/session.dto'; import { SessionsResponseDto } from './dto/session.dto';
import { LoginAuditService } from '../services/login-audit.service';
import {
LoginAuditFiltersDto,
LoginAuditResponseDto,
LoginStatsDto,
LoginStatsFiltersDto
} from './dto/login-audit.dto';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -44,6 +53,7 @@ export class AuthController {
private readonly rateLimitingService: RateLimitingService, private readonly rateLimitingService: RateLimitingService,
private readonly refreshTokenService: RefreshTokenService, private readonly refreshTokenService: RefreshTokenService,
private readonly sessionManagementService: SessionManagementService, private readonly sessionManagementService: SessionManagementService,
private readonly loginAuditService: LoginAuditService,
) {} ) {}
@Post('login') @Post('login')
@@ -62,30 +72,47 @@ export class AuthController {
const command = new AuthenticateUserCommand(dto.username, dto.password); const command = new AuthenticateUserCommand(dto.username, dto.password);
const result = await this.commandBus.execute(command); const result = await this.commandBus.execute(command);
const userAgent = req.headers['user-agent'] || 'Unknown';
if (!result.success) { if (!result.success) {
// Registra tentativa falhada
await this.rateLimitingService.recordAttempt(ip, false); await this.rateLimitingService.recordAttempt(ip, false);
await this.loginAuditService.logLoginAttempt({
username: dto.username,
ipAddress: ip,
userAgent,
success: false,
failureReason: result.error,
});
throw new HttpException( throw new HttpException(
new ResultModel(false, result.error, null, result.error), new ResultModel(false, result.error, null, result.error),
HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED,
); );
} }
// Registra tentativa bem-sucedida (limpa contador)
await this.rateLimitingService.recordAttempt(ip, true); await this.rateLimitingService.recordAttempt(ip, true);
const user = result.data; const user = result.data;
const userAgent = req.headers['user-agent'] || 'Unknown';
// Cria sessão para o usuário primeiro /**
* Verifica se o usuário já possui uma sessão ativa
*/
const existingSession = await this.sessionManagementService.hasActiveSession(user.id);
if (existingSession) {
/**
* Encerra a sessão existente antes de criar uma nova
*/
await this.sessionManagementService.terminateSession(user.id, existingSession.sessionId);
}
const session = await this.sessionManagementService.createSession( const session = await this.sessionManagementService.createSession(
user.id, user.id,
ip, ip,
userAgent, userAgent,
); );
// Cria tokens de acesso e refresh com sessionId
const tokenPair = await this.authService.createTokenPair( const tokenPair = await this.authService.createTokenPair(
user.id, user.id,
user.sellerId, user.sellerId,
@@ -95,11 +122,20 @@ export class AuthController {
session.sessionId, session.sessionId,
); );
await this.loginAuditService.logLoginAttempt({
userId: user.id,
username: dto.username,
ipAddress: ip,
userAgent,
success: true,
sessionId: session.sessionId,
});
return { return {
id: user.id, id: user.id,
sellerId: user.sellerId, sellerId: user.sellerId,
name: user.name, name: user.name,
username: user.name, username: dto.username,
storeId: user.storeId, storeId: user.storeId,
email: user.email, email: user.email,
accessToken: tokenPair.accessToken, accessToken: tokenPair.accessToken,
@@ -173,7 +209,7 @@ export class AuthController {
@ApiUnauthorizedResponse({ description: 'Token inválido ou expirado' }) @ApiUnauthorizedResponse({ description: 'Token inválido ou expirado' })
async getSessions(@Request() req): Promise<SessionsResponseDto> { async getSessions(@Request() req): Promise<SessionsResponseDto> {
const userId = req.user.id; const userId = req.user.id;
const currentSessionId = req.user.sessionId; // ID da sessão atual const currentSessionId = req.user.sessionId;
const sessions = await this.sessionManagementService.getActiveSessions(userId, currentSessionId); const sessions = await this.sessionManagementService.getActiveSessions(userId, currentSessionId);
return { return {
@@ -181,8 +217,8 @@ export class AuthController {
sessionId: session.sessionId, sessionId: session.sessionId,
ipAddress: session.ipAddress, ipAddress: session.ipAddress,
userAgent: session.userAgent, userAgent: session.userAgent,
createdAt: new Date(session.createdAt).toISOString(), createdAt: DateUtil.toBrazilISOString(new Date(session.createdAt)),
lastActivity: new Date(session.lastActivity).toISOString(), lastActivity: DateUtil.toBrazilISOString(new Date(session.lastActivity)),
isCurrent: session.sessionId === currentSessionId, isCurrent: session.sessionId === currentSessionId,
})), })),
total: sessions.length, total: sessions.length,
@@ -222,4 +258,132 @@ export class AuthController {
message: 'Todas as sessões foram encerradas com sucesso', message: 'Todas as sessões foram encerradas com sucesso',
}; };
} }
@Get('audit/logs')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Consulta logs de auditoria de login' })
@ApiOkResponse({
description: 'Lista de logs de auditoria',
type: LoginAuditResponseDto,
})
@ApiUnauthorizedResponse({ description: 'Token inválido ou expirado' })
async getLoginAuditLogs(
@Query() filters: LoginAuditFiltersDto,
@Request() req,
): Promise<LoginAuditResponseDto> {
const userId = req.user.id;
const auditFilters = {
...filters,
userId: filters.userId || userId,
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
endDate: filters.endDate ? new Date(filters.endDate) : undefined,
};
const logs = await this.loginAuditService.getLoginLogs(auditFilters);
return {
logs: logs.map(log => ({
...log,
timestamp: DateUtil.toBrazilISOString(log.timestamp),
})),
total: logs.length,
page: Math.floor((filters.offset || 0) / (filters.limit || 100)) + 1,
limit: filters.limit || 100,
};
}
@Get('audit/stats')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Obtém estatísticas de login' })
@ApiOkResponse({
description: 'Estatísticas de login',
type: LoginStatsDto,
})
@ApiUnauthorizedResponse({ description: 'Token inválido ou expirado' })
async getLoginStats(
@Query() filters: LoginStatsFiltersDto,
@Request() req,
): Promise<LoginStatsDto> {
const userId = req.user.id;
const days = filters.days || 7;
const stats = await this.loginAuditService.getLoginStats(
filters.userId || userId,
days,
);
return stats;
}
@Get('session/status')
@ApiOperation({ summary: 'Verifica se o usuário possui uma sessão ativa' })
@ApiOkResponse({
description: 'Status da sessão do usuário',
schema: {
type: 'object',
properties: {
hasActiveSession: { type: 'boolean' },
sessionInfo: {
type: 'object',
properties: {
sessionId: { type: 'string' },
ipAddress: { type: 'string' },
userAgent: { type: 'string' },
createdAt: { type: 'string' },
lastActivity: { type: 'string' }
}
}
}
}
})
async checkSessionStatus(@Query('username') username: string): Promise<{
hasActiveSession: boolean;
sessionInfo?: {
sessionId: string;
ipAddress: string;
userAgent: string;
createdAt: string;
lastActivity: string;
};
}> {
if (!username) {
throw new HttpException(
new ResultModel(false, 'Username é obrigatório', null, 'Username é obrigatório'),
HttpStatus.BAD_REQUEST,
);
}
/**
* Busca o usuário pelo username para obter o ID
*/
const user = await this.authService.findUserByUsername(username);
if (!user) {
return {
hasActiveSession: false,
};
}
const activeSession = await this.sessionManagementService.hasActiveSession(user.id);
if (!activeSession) {
return {
hasActiveSession: false,
};
}
return {
hasActiveSession: true,
sessionInfo: {
sessionId: activeSession.sessionId,
ipAddress: activeSession.ipAddress,
userAgent: activeSession.userAgent,
createdAt: DateUtil.toBrazilISOString(new Date(activeSession.createdAt)),
lastActivity: DateUtil.toBrazilISOString(new Date(activeSession.lastActivity)),
},
};
}
} }

View File

@@ -13,6 +13,7 @@ import { TokenBlacklistService } from '../services/token-blacklist.service';
import { RateLimitingService } from '../services/rate-limiting.service'; import { RateLimitingService } from '../services/rate-limiting.service';
import { RefreshTokenService } from '../services/refresh-token.service'; import { RefreshTokenService } from '../services/refresh-token.service';
import { SessionManagementService } from '../services/session-management.service'; import { SessionManagementService } from '../services/session-management.service';
import { LoginAuditService } from '../services/login-audit.service';
@Module({ @Module({
imports: [ imports: [
@@ -40,6 +41,7 @@ import { SessionManagementService } from '../services/session-management.service
RateLimitingService, RateLimitingService,
RefreshTokenService, RefreshTokenService,
SessionManagementService, SessionManagementService,
LoginAuditService,
AuthenticateUserHandler AuthenticateUserHandler
], ],
exports: [AuthService], exports: [AuthService],

View File

@@ -37,11 +37,12 @@ export class AuthService {
* @param username Nome de usuário * @param username Nome de usuário
* @param email Email do usuário * @param email Email do usuário
* @param storeId ID da loja * @param storeId ID da loja
* @param sessionId ID da sessão
* @returns Objeto com access token e refresh token * @returns Objeto com access token e refresh token
*/ */
async createTokenPair(id: number, sellerId: number, username: string, email: string, storeId: string, sessionId?: string) { async createTokenPair(id: number, sellerId: number, username: string, email: string, storeId: string, sessionId?: string) {
const accessToken = await this.createToken(id, sellerId, username, email, storeId, sessionId); const accessToken = await this.createToken(id, sellerId, username, email, storeId, sessionId);
const refreshToken = await this.refreshTokenService.generateRefreshToken(id); const refreshToken = await this.refreshTokenService.generateRefreshToken(id, sessionId);
return { return {
accessToken, accessToken,
@@ -68,7 +69,8 @@ export class AuthService {
user.sellerId, user.sellerId,
user.name, user.name,
user.email, user.email,
user.storeId user.storeId,
tokenData.sessionId
); );
return { return {
@@ -85,7 +87,7 @@ export class AuthService {
id: user.id, id: user.id,
sellerId: user.sellerId, sellerId: user.sellerId,
storeId: user.storeId, storeId: user.storeId,
username: user.name, // Usando name como username para consistência username: user.name,
email: user.email, email: user.email,
}; };
} }
@@ -106,4 +108,13 @@ export class AuthService {
async isTokenBlacklisted(token: string): Promise<boolean> { async isTokenBlacklisted(token: string): Promise<boolean> {
return this.tokenBlacklistService.isBlacklisted(token); return this.tokenBlacklistService.isBlacklisted(token);
} }
/**
* Busca um usuário pelo username
* @param username Nome de usuário
* @returns Dados do usuário se encontrado
*/
async findUserByUsername(username: string) {
return this.userRepository.findByUsername(username);
}
} }

View File

@@ -0,0 +1,139 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsNumber, IsString, IsBoolean, IsDateString, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class LoginAuditFiltersDto {
@ApiProperty({ description: 'ID do usuário', required: false })
@IsOptional()
@IsNumber()
@Type(() => Number)
userId?: number;
@ApiProperty({ description: 'Nome de usuário', required: false })
@IsOptional()
@IsString()
username?: string;
@ApiProperty({ description: 'Endereço IP', required: false })
@IsOptional()
@IsString()
ipAddress?: string;
@ApiProperty({ description: 'Filtrar apenas logins bem-sucedidos', required: false })
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
success?: boolean;
@ApiProperty({ description: 'Data de início (ISO string)', required: false })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiProperty({ description: 'Data de fim (ISO string)', required: false })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiProperty({ description: 'Número de registros por página', required: false, minimum: 1, maximum: 1000 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(1000)
limit?: number;
@ApiProperty({ description: 'Offset para paginação', required: false, minimum: 0 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
offset?: number;
}
export class LoginAuditLogDto {
@ApiProperty({ description: 'ID único do log' })
id: string;
@ApiProperty({ description: 'ID do usuário', required: false })
userId?: number;
@ApiProperty({ description: 'Nome de usuário' })
username: string;
@ApiProperty({ description: 'Endereço IP' })
ipAddress: string;
@ApiProperty({ description: 'User Agent' })
userAgent: string;
@ApiProperty({ description: 'Login bem-sucedido' })
success: boolean;
@ApiProperty({ description: 'Motivo da falha', required: false })
failureReason?: string;
@ApiProperty({ description: 'Timestamp do login' })
timestamp: string;
@ApiProperty({ description: 'ID da sessão', required: false })
sessionId?: string;
@ApiProperty({ description: 'Localização estimada', required: false })
location?: string;
}
export class LoginAuditResponseDto {
@ApiProperty({ description: 'Lista de logs de login', type: [LoginAuditLogDto] })
logs: LoginAuditLogDto[];
@ApiProperty({ description: 'Total de registros encontrados' })
total: number;
@ApiProperty({ description: 'Página atual' })
page: number;
@ApiProperty({ description: 'Registros por página' })
limit: number;
}
export class LoginStatsDto {
@ApiProperty({ description: 'Total de tentativas' })
totalAttempts: number;
@ApiProperty({ description: 'Logins bem-sucedidos' })
successfulLogins: number;
@ApiProperty({ description: 'Logins falhados' })
failedLogins: number;
@ApiProperty({ description: 'Número de IPs únicos' })
uniqueIps: number;
@ApiProperty({ description: 'IPs mais frequentes' })
topIps: Array<{ ip: string; count: number }>;
@ApiProperty({ description: 'Estatísticas diárias' })
dailyStats: Array<{
date: string;
attempts: number;
successes: number;
failures: number;
}>;
}
export class LoginStatsFiltersDto {
@ApiProperty({ description: 'ID do usuário para estatísticas', required: false })
@IsOptional()
@IsNumber()
@Type(() => Number)
userId?: number;
@ApiProperty({ description: 'Número de dias para análise', required: false, minimum: 1, maximum: 365 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(365)
days?: number;
}

View File

@@ -0,0 +1,295 @@
import { Injectable, Inject } from '@nestjs/common';
import Redis from 'ioredis';
import { DateUtil } from 'src/shared/date.util';
export interface LoginAuditLog {
id: string;
userId?: number;
username: string;
ipAddress: string;
userAgent: string;
success: boolean;
failureReason?: string;
timestamp: Date;
sessionId?: string;
location?: string;
}
export interface LoginAuditFilters {
userId?: number;
username?: string;
ipAddress?: string;
success?: boolean;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
}
@Injectable()
export class LoginAuditService {
private readonly LOG_PREFIX = 'login_audit';
private readonly LOG_EXPIRY = 30 * 24 * 60 * 60; // 30 dias em segundos
constructor(
@Inject('REDIS_CLIENT') private readonly redis: Redis,
) {}
/**
* Registra uma tentativa de login
* @param log Dados do log de login
*/
async logLoginAttempt(log: Omit<LoginAuditLog, 'id' | 'timestamp'>): Promise<void> {
const logId = this.generateLogId();
const timestamp = DateUtil.now();
const auditLog: LoginAuditLog = {
...log,
id: logId,
timestamp,
};
const logKey = this.buildLogKey(logId);
await this.redis.setex(logKey, this.LOG_EXPIRY, JSON.stringify(auditLog));
if (log.userId) {
const userLogsKey = this.buildUserLogsKey(log.userId);
await this.redis.lpush(userLogsKey, logId);
await this.redis.expire(userLogsKey, this.LOG_EXPIRY);
}
const ipLogsKey = this.buildIpLogsKey(log.ipAddress);
await this.redis.lpush(ipLogsKey, logId);
await this.redis.expire(ipLogsKey, this.LOG_EXPIRY);
const globalLogsKey = this.buildGlobalLogsKey();
await this.redis.lpush(globalLogsKey, logId);
await this.redis.ltrim(globalLogsKey, 0, 999);
await this.redis.expire(globalLogsKey, this.LOG_EXPIRY);
const dateKey = DateUtil.toBrazilString(timestamp, 'yyyy-MM-dd');
const dateLogsKey = this.buildDateLogsKey(dateKey);
await this.redis.lpush(dateLogsKey, logId);
await this.redis.expire(dateLogsKey, this.LOG_EXPIRY);
}
/**
* Busca logs de login com filtros
* @param filters Filtros para a busca
* @returns Lista de logs de login
*/
async getLoginLogs(filters: LoginAuditFilters = {}): Promise<LoginAuditLog[]> {
let logIds: string[] = [];
if (filters.userId) {
const userLogsKey = this.buildUserLogsKey(filters.userId);
logIds = await this.redis.lrange(userLogsKey, 0, -1);
} else if (filters.ipAddress) {
const ipLogsKey = this.buildIpLogsKey(filters.ipAddress);
logIds = await this.redis.lrange(ipLogsKey, 0, -1);
} else if (filters.startDate || filters.endDate) {
const startDate = filters.startDate || new Date(DateUtil.nowTimestamp() - 7 * 24 * 60 * 60 * 1000);
const endDate = filters.endDate || DateUtil.now();
const dates = this.getDateRange(startDate, endDate);
for (const date of dates) {
const dateLogsKey = this.buildDateLogsKey(date);
const dateLogIds = await this.redis.lrange(dateLogsKey, 0, -1);
logIds.push(...dateLogIds);
}
} else {
const globalLogsKey = this.buildGlobalLogsKey();
logIds = await this.redis.lrange(globalLogsKey, 0, -1);
}
const logs: LoginAuditLog[] = [];
for (const logId of logIds) {
const logKey = this.buildLogKey(logId);
const logData = await this.redis.get(logKey);
if (logData) {
const log: LoginAuditLog = JSON.parse(logData as string);
/**
* Converte timestamp de string para Date se necessário
*/
if (typeof log.timestamp === 'string') {
log.timestamp = new Date(log.timestamp);
}
if (this.matchesFilters(log, filters)) {
logs.push(log);
}
}
}
logs.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
const offset = filters.offset || 0;
const limit = filters.limit || 100;
return logs.slice(offset, offset + limit);
}
/**
* Busca estatísticas de login
* @param userId ID do usuário (opcional)
* @param days Número de dias para análise (padrão: 7)
* @returns Estatísticas de login
*/
async getLoginStats(userId?: number, days: number = 7): Promise<{
totalAttempts: number;
successfulLogins: number;
failedLogins: number;
uniqueIps: number;
topIps: Array<{ ip: string; count: number }>;
dailyStats: Array<{ date: string; attempts: number; successes: number; failures: number }>;
}> {
const endDate = DateUtil.now();
const startDate = new Date(endDate.getTime() - days * 24 * 60 * 60 * 1000);
const filters: LoginAuditFilters = {
startDate,
endDate,
limit: 10000, // Limite alto para estatísticas
};
if (userId) {
filters.userId = userId;
}
const logs = await this.getLoginLogs(filters);
const stats = {
totalAttempts: logs.length,
successfulLogins: logs.filter(log => log.success).length,
failedLogins: logs.filter(log => !log.success).length,
uniqueIps: new Set(logs.map(log => log.ipAddress)).size,
topIps: [] as Array<{ ip: string; count: number }>,
dailyStats: [] as Array<{ date: string; attempts: number; successes: number; failures: number }>,
};
const ipCounts = new Map<string, number>();
logs.forEach(log => {
ipCounts.set(log.ipAddress, (ipCounts.get(log.ipAddress) || 0) + 1);
});
stats.topIps = Array.from(ipCounts.entries())
.map(([ip, count]) => ({ ip, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const dailyCounts = new Map<string, { attempts: number; successes: number; failures: number }>();
logs.forEach(log => {
const date = DateUtil.toBrazilString(log.timestamp, 'yyyy-MM-dd');
const dayStats = dailyCounts.get(date) || { attempts: 0, successes: 0, failures: 0 };
dayStats.attempts++;
if (log.success) {
dayStats.successes++;
} else {
dayStats.failures++;
}
dailyCounts.set(date, dayStats);
});
stats.dailyStats = Array.from(dailyCounts.entries())
.map(([date, counts]) => ({ date, ...counts }))
.sort((a, b) => a.date.localeCompare(b.date));
return stats;
}
/**
* Remove logs antigos (mais de 30 dias)
*/
async cleanupOldLogs(): Promise<void> {
const cutoffDate = new Date(DateUtil.nowTimestamp() - 30 * 24 * 60 * 60 * 1000);
const cutoffDateStr = DateUtil.toBrazilString(cutoffDate, 'yyyy-MM-dd');
const oldDates = this.getDateRange(new Date('2020-01-01'), cutoffDate);
for (const date of oldDates) {
const dateLogsKey = this.buildDateLogsKey(date);
await this.redis.del(dateLogsKey);
}
}
/**
* Gera um ID único para o log
*/
private generateLogId(): string {
return `${DateUtil.nowTimestamp()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Constrói a chave para um log específico
*/
private buildLogKey(logId: string): string {
return `${this.LOG_PREFIX}:log:${logId}`;
}
/**
* Constrói a chave para logs de um usuário
*/
private buildUserLogsKey(userId: number): string {
return `${this.LOG_PREFIX}:user:${userId}`;
}
/**
* Constrói a chave para logs de um IP
*/
private buildIpLogsKey(ipAddress: string): string {
return `${this.LOG_PREFIX}:ip:${ipAddress}`;
}
/**
* Constrói a chave para logs globais
*/
private buildGlobalLogsKey(): string {
return `${this.LOG_PREFIX}:global`;
}
/**
* Constrói a chave para logs de uma data específica
*/
private buildDateLogsKey(date: string): string {
return `${this.LOG_PREFIX}:date:${date}`;
}
/**
* Verifica se um log corresponde aos filtros
*/
private matchesFilters(log: LoginAuditLog, filters: LoginAuditFilters): boolean {
if (filters.username && !log.username.toLowerCase().includes(filters.username.toLowerCase())) {
return false;
}
if (filters.success !== undefined && log.success !== filters.success) {
return false;
}
if (filters.startDate && log.timestamp < filters.startDate) {
return false;
}
if (filters.endDate && log.timestamp > filters.endDate) {
return false;
}
return true;
}
/**
* Gera array de datas entre startDate e endDate
*/
private getDateRange(startDate: Date, endDate: Date): string[] {
const dates: string[] = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
dates.push(DateUtil.toBrazilString(currentDate, 'yyyy-MM-dd'));
currentDate.setDate(currentDate.getDate() + 1);
}
return dates;
}
}

View File

@@ -21,7 +21,7 @@ export class RateLimitingService {
) {} ) {}
/** /**
* Verifica se o IP pode fazer uma tentativa de login * Verifica se o IP pode fazer uma tentativa de login usando operações atômicas
* @param ip Endereço IP do cliente * @param ip Endereço IP do cliente
* @param config Configuração personalizada (opcional) * @param config Configuração personalizada (opcional)
* @returns true se permitido, false se bloqueado * @returns true se permitido, false se bloqueado
@@ -31,23 +31,51 @@ export class RateLimitingService {
const key = this.buildAttemptKey(ip); const key = this.buildAttemptKey(ip);
const blockKey = this.buildBlockKey(ip); const blockKey = this.buildBlockKey(ip);
// Verifica se está bloqueado /**
const isBlocked = await this.redis.get(blockKey); * Usa script Lua para operação atômica (verificação e incremento em uma única operação)
if (isBlocked) { */
return false; const luaScript = `
} local key = KEYS[1]
local blockKey = KEYS[2]
local maxAttempts = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local blockDurationMs = tonumber(ARGV[3])
// Conta tentativas na janela de tempo -- Verifica se já está bloqueado
const attempts = await this.redis.get<string>(key); local isBlocked = redis.call('GET', blockKey)
const attemptCount = attempts ? parseInt(attempts) : 0; if isBlocked then
return {0, 1} -- attempts=0, blocked=1
end
if (attemptCount >= finalConfig.maxAttempts) { -- Incrementa contador de tentativas
// Bloqueia o IP local attempts = redis.call('INCR', key)
await this.redis.set(blockKey, 'blocked', finalConfig.blockDurationMs / 1000);
return false;
}
return true; -- Se é a primeira tentativa, define TTL
if attempts == 1 then
redis.call('EXPIRE', key, windowMs / 1000)
end
-- Se excedeu limite, bloqueia
if attempts > maxAttempts then
redis.call('SET', blockKey, 'blocked', 'EX', blockDurationMs / 1000)
return {attempts, 1} -- attempts, blocked=1
end
return {attempts, 0} -- attempts, blocked=0
`;
const result = await this.redis.eval(
luaScript,
2,
key,
blockKey,
finalConfig.maxAttempts,
finalConfig.windowMs,
finalConfig.blockDurationMs
) as [number, number];
const [attempts, isBlockedResult] = result;
return isBlockedResult === 0;
} }
/** /**
@@ -59,15 +87,18 @@ export class RateLimitingService {
async recordAttempt(ip: string, success: boolean, config?: Partial<RateLimitConfig>): Promise<void> { async recordAttempt(ip: string, success: boolean, config?: Partial<RateLimitConfig>): Promise<void> {
const finalConfig = { ...this.defaultConfig, ...config }; const finalConfig = { ...this.defaultConfig, ...config };
const key = this.buildAttemptKey(ip); const key = this.buildAttemptKey(ip);
const blockKey = this.buildBlockKey(ip);
if (success) { if (success) {
/**
* Limpa tentativas e bloqueio em caso de sucesso
*/
await this.redis.del(key); await this.redis.del(key);
} else { await this.redis.del(blockKey);
const attempts = await this.redis.get<string>(key);
const attemptCount = attempts ? parseInt(attempts) + 1 : 1;
await this.redis.set(key, attemptCount.toString(), finalConfig.windowMs / 1000);
} }
/**
* Para falhas, o incremento já foi feito no isAllowed() de forma atômica
*/
} }
/** /**

View File

@@ -4,10 +4,12 @@ import { IRedisClient } from '../../core/configs/cache/IRedisClient';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { JwtPayload } from '../models/jwt-payload.model'; import { JwtPayload } from '../models/jwt-payload.model';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { DateUtil } from 'src/shared/date.util';
export interface RefreshTokenData { export interface RefreshTokenData {
userId: number; userId: number;
tokenId: string; tokenId: string;
sessionId?: string;
expiresAt: number; expiresAt: number;
createdAt: number; createdAt: number;
} }
@@ -25,26 +27,30 @@ export class RefreshTokenService {
/** /**
* Gera um novo refresh token para o usuário * Gera um novo refresh token para o usuário
* @param userId ID do usuário * @param userId ID do usuário
* @param sessionId ID da sessão (opcional)
* @returns Refresh token * @returns Refresh token
*/ */
async generateRefreshToken(userId: number): Promise<string> { async generateRefreshToken(userId: number, sessionId?: string): Promise<string> {
const tokenId = randomBytes(32).toString('hex'); const tokenId = randomBytes(32).toString('hex');
const refreshToken = this.jwtService.sign( const refreshToken = this.jwtService.sign(
{ userId, tokenId, type: 'refresh' }, { userId, tokenId, sessionId, type: 'refresh' },
{ expiresIn: '7d' } { expiresIn: '7d' }
); );
const tokenData: RefreshTokenData = { const tokenData: RefreshTokenData = {
userId, userId,
tokenId, tokenId,
expiresAt: Date.now() + (this.REFRESH_TOKEN_TTL * 1000), sessionId,
createdAt: Date.now(), expiresAt: DateUtil.nowTimestamp() + (this.REFRESH_TOKEN_TTL * 1000),
createdAt: DateUtil.nowTimestamp(),
}; };
const key = this.buildRefreshTokenKey(userId, tokenId); const key = this.buildRefreshTokenKey(userId, tokenId);
await this.redis.set(key, tokenData, this.REFRESH_TOKEN_TTL); await this.redis.set(key, tokenData, this.REFRESH_TOKEN_TTL);
// Limita o número de refresh tokens por usuário /**
* Limita o número de refresh tokens por usuário
*/
await this.limitRefreshTokensPerUser(userId); await this.limitRefreshTokensPerUser(userId);
return refreshToken; return refreshToken;
@@ -63,7 +69,7 @@ export class RefreshTokenService {
throw new UnauthorizedException('Token inválido'); throw new UnauthorizedException('Token inválido');
} }
const { userId, tokenId } = decoded; const { userId, tokenId, sessionId } = decoded;
const key = this.buildRefreshTokenKey(userId, tokenId); const key = this.buildRefreshTokenKey(userId, tokenId);
const tokenData = await this.redis.get<RefreshTokenData>(key); const tokenData = await this.redis.get<RefreshTokenData>(key);
@@ -71,7 +77,7 @@ export class RefreshTokenService {
throw new UnauthorizedException('Refresh token expirado ou inválido'); throw new UnauthorizedException('Refresh token expirado ou inválido');
} }
if (tokenData.expiresAt < Date.now()) { if (tokenData.expiresAt < DateUtil.nowTimestamp()) {
await this.revokeRefreshToken(userId, tokenId); await this.revokeRefreshToken(userId, tokenId);
throw new UnauthorizedException('Refresh token expirado'); throw new UnauthorizedException('Refresh token expirado');
} }
@@ -82,6 +88,7 @@ export class RefreshTokenService {
storeId: '', storeId: '',
username: '', username: '',
email: '', email: '',
sessionId: sessionId || tokenData.sessionId,
tokenId tokenId
} as JwtPayload; } as JwtPayload;
} catch (error) { } catch (error) {
@@ -125,7 +132,7 @@ export class RefreshTokenService {
for (const key of keys) { for (const key of keys) {
const tokenData = await this.redis.get<RefreshTokenData>(key); const tokenData = await this.redis.get<RefreshTokenData>(key);
if (tokenData && tokenData.expiresAt > Date.now()) { if (tokenData && tokenData.expiresAt > DateUtil.nowTimestamp()) {
tokens.push(tokenData); tokens.push(tokenData);
} }
} }
@@ -141,7 +148,9 @@ export class RefreshTokenService {
const activeTokens = await this.getActiveRefreshTokens(userId); const activeTokens = await this.getActiveRefreshTokens(userId);
if (activeTokens.length > this.MAX_REFRESH_TOKENS_PER_USER) { if (activeTokens.length > this.MAX_REFRESH_TOKENS_PER_USER) {
// Remove os tokens mais antigos /**
* Remove os tokens mais antigos
*/
const tokensToRemove = activeTokens const tokensToRemove = activeTokens
.slice(this.MAX_REFRESH_TOKENS_PER_USER) .slice(this.MAX_REFRESH_TOKENS_PER_USER)
.map(token => token.tokenId); .map(token => token.tokenId);

View File

@@ -2,6 +2,7 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { RedisClientToken } from '../../core/configs/cache/redis-client.adapter.provider'; import { RedisClientToken } from '../../core/configs/cache/redis-client.adapter.provider';
import { IRedisClient } from '../../core/configs/cache/IRedisClient'; import { IRedisClient } from '../../core/configs/cache/IRedisClient';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { DateUtil } from 'src/shared/date.util';
export interface SessionData { export interface SessionData {
sessionId: string; sessionId: string;
@@ -16,7 +17,7 @@ export interface SessionData {
@Injectable() @Injectable()
export class SessionManagementService { export class SessionManagementService {
private readonly SESSION_TTL = 8 * 60 * 60; // 8 horas em segundos private readonly SESSION_TTL = 8 * 60 * 60; // 8 horas em segundos
private readonly MAX_SESSIONS_PER_USER = 5; // Máximo 5 sessões por usuário private readonly MAX_SESSIONS_PER_USER = 1; // Máximo 1 sessão por usuário
constructor( constructor(
@Inject(RedisClientToken) private readonly redis: IRedisClient, @Inject(RedisClientToken) private readonly redis: IRedisClient,
@@ -31,7 +32,7 @@ export class SessionManagementService {
*/ */
async createSession(userId: number, ipAddress: string, userAgent: string): Promise<SessionData> { async createSession(userId: number, ipAddress: string, userAgent: string): Promise<SessionData> {
const sessionId = randomBytes(16).toString('hex'); const sessionId = randomBytes(16).toString('hex');
const now = Date.now(); const now = DateUtil.nowTimestamp();
const sessionData: SessionData = { const sessionData: SessionData = {
sessionId, sessionId,
@@ -62,7 +63,7 @@ export class SessionManagementService {
const sessionData = await this.redis.get<SessionData>(key); const sessionData = await this.redis.get<SessionData>(key);
if (sessionData) { if (sessionData) {
sessionData.lastActivity = Date.now(); sessionData.lastActivity = DateUtil.nowTimestamp();
await this.redis.set(key, sessionData, this.SESSION_TTL); await this.redis.set(key, sessionData, this.SESSION_TTL);
} }
} }
@@ -158,6 +159,16 @@ export class SessionManagementService {
return sessionData ? sessionData.isActive : false; return sessionData ? sessionData.isActive : false;
} }
/**
* Verifica se o usuário possui uma sessão ativa
* @param userId ID do usuário
* @returns Dados da sessão ativa se existir, null caso contrário
*/
async hasActiveSession(userId: number): Promise<SessionData | null> {
const activeSessions = await this.getActiveSessions(userId);
return activeSessions.length > 0 ? activeSessions[0] : null;
}
/** /**
* Limita o número de sessões por usuário * Limita o número de sessões por usuário
* @param userId ID do usuário * @param userId ID do usuário

View File

@@ -31,17 +31,34 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Token foi invalidado'); throw new UnauthorizedException('Token foi invalidado');
} }
const sessionKey = this.buildSessionKey(payload.id); /**
* Usa a mesma chave que o SessionManagementService
* Formato: auth:sessions:userId:sessionId
*/
const sessionKey = this.buildSessionKey(payload.id, payload.sessionId);
const cachedUser = await this.redis.get<any>(sessionKey); const cachedUser = await this.redis.get<any>(sessionKey);
if (cachedUser) { if (cachedUser) {
/**
* Verifica se a sessão ainda está ativa
*/
const isSessionActive = await this.sessionManagementService.isSessionActive(
payload.id,
payload.sessionId
);
if (!isSessionActive) {
throw new UnauthorizedException('Sessão expirada ou inválida');
}
return { return {
id: cachedUser.id, id: cachedUser.id,
sellerId: cachedUser.sellerId, sellerId: cachedUser.sellerId,
storeId: cachedUser.storeId, storeId: cachedUser.storeId,
username: cachedUser.name, username: cachedUser.username, // ← Corrigido: usar username em vez de name
email: cachedUser.email, email: cachedUser.email,
name: cachedUser.name, name: cachedUser.name,
sessionId: payload.sessionId,
}; };
} }
@@ -50,14 +67,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Usuário inválido ou inativo'); throw new UnauthorizedException('Usuário inválido ou inativo');
} }
/**
* Verifica se usuário está bloqueado (consistência com AuthenticateUserHandler)
*/
if (user.situacao === 'B') {
throw new UnauthorizedException('Usuário bloqueado, acesso não permitido');
}
const userData = { const userData = {
id: user.id, id: user.id,
sellerId: user.sellerId, sellerId: user.sellerId,
storeId: user.storeId, storeId: user.storeId,
username: user.name, username: user.name, // ← Manter name como username para compatibilidade
email: user.email, email: user.email,
name: user.name, name: user.name,
sessionId: payload.sessionId, // Inclui sessionId do token sessionId: payload.sessionId,
}; };
await this.redis.set(sessionKey, userData, 60 * 60 * 8); await this.redis.set(sessionKey, userData, 60 * 60 * 8);
@@ -65,7 +89,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
return userData; return userData;
} }
private buildSessionKey(userId: number): string { /**
return `auth:sessions:${userId}`; * Constrói a chave de sessão no mesmo formato do SessionManagementService
* @param userId ID do usuário
* @param sessionId ID da sessão
* @returns Chave para o Redis
*/
private buildSessionKey(userId: number, sessionId: string): string {
return `auth:sessions:${userId}:${sessionId}`;
} }
} }

View File

@@ -73,4 +73,16 @@ export class UserRepository {
const result = await this.dataSource.query(sql, [id]); const result = await this.dataSource.query(sql, [id]);
return result[0] || null; return result[0] || null;
} }
async findByUsername(username: string) {
const sql = `
SELECT MATRICULA AS "id", NOME AS "name", CODUSUR AS "sellerId",
CODFILIAL AS "storeId", EMAIL AS "email",
DTDEMISSAO as "dataDesligamento", SITUACAO as "situacao"
FROM PCEMPR
WHERE USUARIOBD = :1
`;
const result = await this.dataSource.query(sql, [username.toUpperCase()]);
return result[0] || null;
}
} }

View File

@@ -5,5 +5,6 @@ export interface IRedisClient {
del(...keys: string[]): Promise<void>; del(...keys: string[]): Promise<void>;
keys(pattern: string): Promise<string[]>; keys(pattern: string): Promise<string[]>;
ttl(key: string): Promise<number>; ttl(key: string): Promise<number>;
eval(script: string, numKeys: number, ...keysAndArgs: (string | number)[]): Promise<any>;
} }

View File

@@ -35,4 +35,8 @@ export class RedisClientAdapter implements IRedisClient {
async ttl(key: string): Promise<number> { async ttl(key: string): Promise<number> {
return this.redis.ttl(key); return this.redis.ttl(key);
} }
async eval(script: string, numKeys: number, ...keysAndArgs: (string | number)[]): Promise<any> {
return this.redis.eval(script, numKeys, ...keysAndArgs);
}
} }

View File

@@ -33,7 +33,7 @@ export function createOracleConfig(config: ConfigService): DataSourceOptions {
username: config.get('ORACLE_USER'), username: config.get('ORACLE_USER'),
password: config.get('ORACLE_PASSWORD'), password: config.get('ORACLE_PASSWORD'),
synchronize: false, synchronize: false,
logging: config.get('NODE_ENV') === 'development', logging: false,
entities: [__dirname + '/../**/*.entity.{ts,js}'], entities: [__dirname + '/../**/*.entity.{ts,js}'],
extra: { extra: {
poolMin: validPoolMin, poolMin: validPoolMin,

View File

@@ -0,0 +1,21 @@
import { ApiTags, ApiOperation, ApiParam, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { Controller, Get, Param } from '@nestjs/common';
import { clientesService } from './clientes.service';
@ApiTags('clientes')
@Controller('api/v1/')
export class clientesController {
constructor(private readonly clientesService: clientesService) {}
@Get('clientes/:filter')
async customer(@Param('filter') filter: string) {
return this.clientesService.customers(filter);
}
}

View File

@@ -0,0 +1,19 @@
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { clientesService } from './clientes.service';
import { clientesController } from './clientes.controller';
/*
https://docs.nestjs.com/modules
*/
import { Module } from '@nestjs/common';
@Module({
imports: [],
controllers: [
clientesController,],
providers: [
clientesService,],
})
export class clientes { }

View File

@@ -0,0 +1,154 @@
import { Injectable, Inject } from '@nestjs/common';
import { QueryRunner, DataSource } from 'typeorm';
import { DATA_SOURCE } from '../core/constants';
import { RedisClientToken } from '../core/configs/cache/redis-client.adapter.provider';
import { IRedisClient } from '../core/configs/cache/IRedisClient';
import { getOrSetCache } from '../shared/cache.util';
@Injectable()
export class clientesService {
private readonly CUSTOMERS_TTL = 60 * 60 * 12; // 12 horas
private readonly CUSTOMERS_CACHE_KEY = 'clientes:search';
constructor(
@Inject(DATA_SOURCE)
private readonly dataSource: DataSource,
@Inject(RedisClientToken)
private readonly redisClient: IRedisClient,
) {}
/**
* Buscar clientes com cache otimizado
* @param filter - Filtro de busca (código, CPF/CNPJ ou nome)
* @returns Array de clientes encontrados
*/
async customers(filter: string) {
const cacheKey = `${this.CUSTOMERS_CACHE_KEY}:${filter}`;
return getOrSetCache(
this.redisClient,
cacheKey,
this.CUSTOMERS_TTL,
async () => {
const queryRunner: QueryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
// Primeira tentativa: busca por código do cliente
let sql = `SELECT PCCLIENT.CODCLI as "id"
,PCCLIENT.CODCLI || ' - '|| PCCLIENT.CLIENTE||
' ( '||REGEXP_REPLACE(PCCLIENT.CGCENT, '[^0-9]', '')||' )' as "name"
,PCCLIENT.ESTCOB as "estcob"
FROM PCCLIENT
WHERE PCCLIENT.CODCLI = REGEXP_REPLACE('${filter}', '[^0-9]', '')
ORDER BY PCCLIENT.CLIENTE`;
let customers = await queryRunner.manager.query(sql);
// Segunda tentativa: busca por CPF/CNPJ se não encontrou por código
if (customers.length === 0) {
sql = `SELECT PCCLIENT.CODCLI as "id",
PCCLIENT.CODCLI || ' - '|| PCCLIENT.CLIENTE||
' ( '||REGEXP_REPLACE(PCCLIENT.CGCENT, '[^0-9]', '')||' )' as "name"
,PCCLIENT.ESTCOB as "estcob"
FROM PCCLIENT
WHERE REGEXP_REPLACE(PCCLIENT.CGCENT, '[^0-9]', '') = REGEXP_REPLACE('${filter}', '[^0-9]', '')
ORDER BY PCCLIENT.CLIENTE`;
customers = await queryRunner.manager.query(sql);
}
// Terceira tentativa: busca por nome do cliente se não encontrou por código ou CPF/CNPJ
if (customers.length === 0) {
sql = `SELECT PCCLIENT.CODCLI as "id",
PCCLIENT.CODCLI || ' - '|| PCCLIENT.CLIENTE||
' ( '||REGEXP_REPLACE(PCCLIENT.CGCENT, '[^0-9]', '')||' )' as "name"
,PCCLIENT.ESTCOB as "estcob"
FROM PCCLIENT
WHERE PCCLIENT.CLIENTE LIKE '${filter.toUpperCase().replace('@', '%')}%'
ORDER BY PCCLIENT.CLIENTE`;
customers = await queryRunner.manager.query(sql);
}
return customers;
} finally {
await queryRunner.release();
}
}
);
}
/**
* Buscar todos os clientes com cache
* @returns Array de todos os clientes
*/
async getAllCustomers() {
const cacheKey = 'clientes:all';
return getOrSetCache(
this.redisClient,
cacheKey,
this.CUSTOMERS_TTL,
async () => {
const queryRunner: QueryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT PCCLIENT.CODCLI as "id"
,PCCLIENT.CODCLI || ' - '|| PCCLIENT.CLIENTE||
' ( '||REGEXP_REPLACE(PCCLIENT.CGCENT, '[^0-9]', '')||' )' as "name"
,PCCLIENT.ESTCOB as "estcob"
FROM PCCLIENT
ORDER BY PCCLIENT.CLIENTE`;
return await queryRunner.manager.query(sql);
} finally {
await queryRunner.release();
}
}
);
}
/**
* Buscar cliente por ID específico com cache
* @param customerId - ID do cliente
* @returns Cliente encontrado ou null
*/
async getCustomerById(customerId: string) {
const cacheKey = `clientes:id:${customerId}`;
return getOrSetCache(
this.redisClient,
cacheKey,
this.CUSTOMERS_TTL,
async () => {
const queryRunner: QueryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT PCCLIENT.CODCLI as "id"
,PCCLIENT.CODCLI || ' - '|| PCCLIENT.CLIENTE||
' ( '||REGEXP_REPLACE(PCCLIENT.CGCENT, '[^0-9]', '')||' )' as "name"
,PCCLIENT.ESTCOB as "estcob"
FROM PCCLIENT
WHERE PCCLIENT.CODCLI = '${customerId}'`;
const customers = await queryRunner.manager.query(sql);
return customers.length > 0 ? customers[0] : null;
} finally {
await queryRunner.release();
}
}
);
}
/**
* Limpar cache de clientes (útil para invalidação)
* @param pattern - Padrão de chaves para limpar (opcional)
*/
async clearCustomersCache(pattern?: string) {
const cachePattern = pattern || 'clientes:*';
// Nota: Esta funcionalidade requer implementação específica do Redis
// Por enquanto, mantemos a interface para futuras implementações
console.log(`Cache de clientes seria limpo para o padrão: ${cachePattern}`);
}
}

View File

@@ -1,5 +1,5 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, UseGuards, UsePipes, ValidationPipe, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiParam, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { DataConsultService } from './data-consult.service'; import { DataConsultService } from './data-consult.service';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard' import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'
import { ProductDto } from './dto/product.dto'; import { ProductDto } from './dto/product.dto';
@@ -7,6 +7,7 @@ import { StoreDto } from './dto/store.dto';
import { SellerDto } from './dto/seller.dto'; import { SellerDto } from './dto/seller.dto';
import { BillingDto } from './dto/billing.dto'; import { BillingDto } from './dto/billing.dto';
import { CustomerDto } from './dto/customer.dto'; import { CustomerDto } from './dto/customer.dto';
import { CarrierDto, FindCarriersDto } from './dto/carrier.dto';
@ApiTags('DataConsult') @ApiTags('DataConsult')
@Controller('api/v1/data-consult') @Controller('api/v1/data-consult')
@@ -71,4 +72,35 @@ export class DataConsultController {
return this.dataConsultService.getAllProducts(); return this.dataConsultService.getAllProducts();
} }
@Get('carriers/all')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Lista todas as transportadoras cadastradas' })
@ApiResponse({ status: 200, description: 'Lista de transportadoras retornada com sucesso', type: [CarrierDto] })
@UsePipes(new ValidationPipe({ transform: true }))
async getAllCarriers(): Promise<CarrierDto[]> {
return this.dataConsultService.getAllCarriers();
}
@Get('carriers')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Busca transportadoras por período de data' })
@ApiResponse({ status: 200, description: 'Lista de transportadoras por período retornada com sucesso', type: [CarrierDto] })
@UsePipes(new ValidationPipe({ transform: true }))
async getCarriersByDate(@Query() query: FindCarriersDto): Promise<CarrierDto[]> {
return this.dataConsultService.getCarriersByDate(query);
}
@Get('carriers/order/:orderId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Busca transportadoras de um pedido específico' })
@ApiParam({ name: 'orderId', example: 236001388 })
@ApiResponse({ status: 200, description: 'Lista de transportadoras do pedido retornada com sucesso', type: [CarrierDto] })
@UsePipes(new ValidationPipe({ transform: true }))
async getOrderCarriers(@Param('orderId', ParseIntPipe) orderId: number): Promise<CarrierDto[]> {
return this.dataConsultService.getOrderCarriers(orderId);
}
} }

View File

@@ -5,9 +5,10 @@ import { DataConsultRepository } from './data-consult.repository';
import { LoggerModule } from 'src/Log/logger.module'; import { LoggerModule } from 'src/Log/logger.module';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { RedisModule } from 'src/core/configs/cache/redis.module'; import { RedisModule } from 'src/core/configs/cache/redis.module';
import { clientes } from './clientes.module';
@Module({ @Module({
imports: [LoggerModule, ConfigModule, RedisModule], imports: [LoggerModule, ConfigModule, RedisModule, clientes],
controllers: [DataConsultController], controllers: [DataConsultController],
providers: [ providers: [
DataConsultService, DataConsultService,

View File

@@ -55,51 +55,166 @@ export class DataConsultRepository {
async findBillings(): Promise<BillingDto[]> { async findBillings(): Promise<BillingDto[]> {
const sql = ` const sql = `
SELECT PCPEDC.NUMPED as "id", SELECT p.CODCOB, p.COBRANCA FROM PCCOB p
PCPEDC.DATA as "date",
PCPEDC.VLTOTAL as "total"
FROM PCPEDC
WHERE PCPEDC.POSICAO = 'F'
`; `;
const results = await this.executeQuery<BillingDto[]>(sql); const results = await this.executeQuery<BillingDto[]>(sql);
return results.map(result => new BillingDto(result)); return results.map(result => new BillingDto(result));
} }
async findCustomers(filter: string): Promise<CustomerDto[]> { async findCustomers(filter: string): Promise<CustomerDto[]> {
const sql = ` // 1) limpa todos os não-dígitos para buscas exatas
SELECT PCCLIENT.CODCLI as "id", const cleanedDigits = filter.replace(/\D/g, '');
PCCLIENT.CLIENTE as "name",
PCCLIENT.CGCENT as "document" // 2) prepara filtro para busca por nome (LIKE)
const likeFilter = `%${filter.toUpperCase().replace(/@/g, '%')}%`;
let customers: CustomerDto[] = [];
// --- 1ª tentativa: busca por código do cliente (CODCLI) ---
let sql = `
SELECT
PCCLIENT.CODCLI AS "id",
PCCLIENT.CLIENTE AS "name",
REGEXP_REPLACE(PCCLIENT.CGCENT,'[^0-9]','') AS "document",
PCCLIENT.ESTCOB AS "estcob"
FROM PCCLIENT FROM PCCLIENT
WHERE PCCLIENT.CLIENTE LIKE :filter WHERE PCCLIENT.CODCLI = :0
OR PCCLIENT.CGCENT LIKE :filter ORDER BY PCCLIENT.CLIENTE
`; `;
const results = await this.executeQuery<CustomerDto[]>(sql, [`%${filter}%`]); customers = await this.executeQuery<CustomerDto[]>(sql, [cleanedDigits]);
return results.map(result => new CustomerDto(result));
// --- 2ª tentativa: busca por CPF/CNPJ (CGCENT) ---
if (customers.length === 0) {
sql = `
SELECT
PCCLIENT.CODCLI AS "id",
PCCLIENT.CLIENTE AS "name",
REGEXP_REPLACE(PCCLIENT.CGCENT,'[^0-9]','') AS "document",
PCCLIENT.ESTCOB AS "estcob"
FROM PCCLIENT
WHERE REGEXP_REPLACE(PCCLIENT.CGCENT,'[^0-9]','') = :0
ORDER BY PCCLIENT.CLIENTE
`;
customers = await this.executeQuery<CustomerDto[]>(sql, [cleanedDigits]);
}
// --- 3ª tentativa: busca parcial por nome ---
if (customers.length === 0) {
sql = `
SELECT
PCCLIENT.CODCLI AS "id",
PCCLIENT.CLIENTE AS "name",
REGEXP_REPLACE(PCCLIENT.CGCENT,'[^0-9]','') AS "document",
PCCLIENT.ESTCOB AS "estcob"
FROM PCCLIENT
WHERE UPPER(PCCLIENT.CLIENTE) LIKE :0
ORDER BY PCCLIENT.CLIENTE
`;
customers = await this.executeQuery<CustomerDto[]>(sql, [likeFilter]);
}
return customers.map(row => new CustomerDto(row));
} }
async findProducts(filter: string): Promise<ProductDto[]> { async findProducts(filter: string): Promise<ProductDto[]> {
const sql = ` const sql = `
SELECT PCPRODUT.CODPROD as "id", SELECT PCPRODUT.CODPROD as "id",
PCPRODUT.DESCRICAO as "name", PCPRODUT.CODPROD || ' - ' || PCPRODUT.DESCRICAO || ' ( ' || PCPRODUT.CODFAB || ' )' as "description"
PCPRODUT.CODFAB as "manufacturerCode"
FROM PCPRODUT FROM PCPRODUT
WHERE PCPRODUT.DESCRICAO LIKE :filter WHERE PCPRODUT.CODPROD = :filter
OR PCPRODUT.CODFAB LIKE :filter
`; `;
const results = await this.executeQuery<ProductDto[]>(sql, [`%${filter}%`]); const results = await this.executeQuery<ProductDto[]>(sql, [filter]);
return results.map(result => new ProductDto(result)); return results.map(result => new ProductDto(result));
} }
async findAllProducts(): Promise<ProductDto[]> { async findAllProducts(): Promise<ProductDto[]> {
const sql = ` const sql = `
SELECT PCPRODUT.CODPROD as "id", SELECT PCPRODUT.CODPROD as "id",
PCPRODUT.DESCRICAO as "name", PCPRODUT.CODPROD || ' - ' || PCPRODUT.DESCRICAO || ' ( ' || PCPRODUT.CODFAB || ' )' as "description"
PCPRODUT.CODFAB as "manufacturerCode"
FROM PCPRODUT FROM PCPRODUT
WHERE ROWNUM <= 500 WHERE ROWNUM <= 500
`; `;
const results = await this.executeQuery<ProductDto[]>(sql); const results = await this.executeQuery<ProductDto[]>(sql);
return results.map(result => new ProductDto(result)); return results.map(result => new ProductDto(result));
} }
/**
* Busca todas as transportadoras cadastradas no sistema
*/
async findAllCarriers(): Promise<any[]> {
const sql = `
SELECT DISTINCT
PCFORNEC.CODFORNEC as "carrierId",
PCFORNEC.FORNECEDOR as "carrierName",
PCFORNEC.CODFORNEC || ' - ' || PCFORNEC.FORNECEDOR as "carrierDescription"
FROM PCFORNEC
WHERE PCFORNEC.CODFORNEC IS NOT NULL
AND PCFORNEC.CODFORNEC > 0
AND PCFORNEC.FORNECEDOR IS NOT NULL
ORDER BY PCFORNEC.FORNECEDOR
`;
return await this.executeQuery<any[]>(sql);
}
/**
* Busca as transportadoras por período de data
*/
async findCarriersByDate(query: any): Promise<any[]> {
let sql = `
SELECT DISTINCT
PCPEDC.CODFORNECFRETE as "carrierId",
PCFORNEC.FORNECEDOR as "carrierName",
PCPEDC.CODFORNECFRETE || ' - ' || PCFORNEC.FORNECEDOR as "carrierDescription",
COUNT(PCPEDC.NUMPED) as "ordersCount"
FROM PCPEDC
LEFT JOIN PCFORNEC ON PCPEDC.CODFORNECFRETE = PCFORNEC.CODFORNEC
WHERE PCPEDC.CODFORNECFRETE IS NOT NULL
AND PCPEDC.CODFORNECFRETE > 0
`;
const conditions: string[] = [];
const parameters: any[] = [];
let paramIndex = 0;
if (query.dateIni) {
conditions.push(`AND PCPEDC.DATA >= TO_DATE(:${paramIndex}, 'YYYY-MM-DD')`);
parameters.push(query.dateIni);
paramIndex++;
}
if (query.dateEnd) {
conditions.push(`AND PCPEDC.DATA <= TO_DATE(:${paramIndex}, 'YYYY-MM-DD')`);
parameters.push(query.dateEnd);
paramIndex++;
}
if (query.codfilial) {
conditions.push(`AND PCPEDC.CODFILIAL = :${paramIndex}`);
parameters.push(query.codfilial);
paramIndex++;
}
sql += "\n" + conditions.join("\n");
sql += "\nGROUP BY PCPEDC.CODFORNECFRETE, PCFORNEC.FORNECEDOR";
sql += "\nORDER BY PCPEDC.CODFORNECFRETE";
return await this.executeQuery<any[]>(sql, parameters);
}
/**
* Busca as transportadoras de um pedido específico
*/
async findOrderCarriers(orderId: number): Promise<any[]> {
const sql = `
SELECT DISTINCT
PCPEDC.CODFORNECFRETE as "carrierId",
PCFORNEC.FORNECEDOR as "carrierName",
PCPEDC.CODFORNECFRETE || ' - ' || PCFORNEC.FORNECEDOR as "carrierDescription"
FROM PCPEDC
LEFT JOIN PCFORNEC ON PCPEDC.CODFORNECFRETE = PCFORNEC.CODFORNEC
WHERE PCPEDC.NUMPED = :0
AND PCPEDC.CODFORNECFRETE IS NOT NULL
AND PCPEDC.CODFORNECFRETE > 0
ORDER BY PCPEDC.CODFORNECFRETE
`;
return await this.executeQuery<any[]>(sql, [orderId]);
}
} }

View File

@@ -5,6 +5,7 @@ import { SellerDto } from './dto/seller.dto';
import { BillingDto } from './dto/billing.dto'; import { BillingDto } from './dto/billing.dto';
import { CustomerDto } from './dto/customer.dto'; import { CustomerDto } from './dto/customer.dto';
import { ProductDto } from './dto/product.dto'; import { ProductDto } from './dto/product.dto';
import { CarrierDto, FindCarriersDto } from './dto/carrier.dto';
import { ILogger } from '../Log/ILogger'; import { ILogger } from '../Log/ILogger';
import { RedisClientToken } from '../core/configs/cache/redis-client.adapter.provider'; import { RedisClientToken } from '../core/configs/cache/redis-client.adapter.provider';
import { IRedisClient } from '../core/configs/cache/IRedisClient'; import { IRedisClient } from '../core/configs/cache/IRedisClient';
@@ -20,6 +21,9 @@ export class DataConsultService {
private readonly BILLINGS_TTL = 3600; private readonly BILLINGS_TTL = 3600;
private readonly ALL_PRODUCTS_CACHE_KEY = 'data-consult:products:all'; private readonly ALL_PRODUCTS_CACHE_KEY = 'data-consult:products:all';
private readonly ALL_PRODUCTS_TTL = 600; private readonly ALL_PRODUCTS_TTL = 600;
private readonly CUSTOMERS_TTL = 3600;
private readonly CARRIERS_CACHE_KEY = 'data-consult:carriers:all';
private readonly CARRIERS_TTL = 3600;
constructor( constructor(
private readonly repository: DataConsultRepository, private readonly repository: DataConsultRepository,
@@ -134,4 +138,71 @@ export class DataConsultService {
throw new HttpException('Erro ao buscar produtos', HttpStatus.INTERNAL_SERVER_ERROR); throw new HttpException('Erro ao buscar produtos', HttpStatus.INTERNAL_SERVER_ERROR);
} }
} }
/**
* Obter todas as transportadoras cadastradas
* @returns Array de CarrierDto
*/
async getAllCarriers(): Promise<CarrierDto[]> {
this.logger.log('Buscando todas as transportadoras');
try {
return getOrSetCache<CarrierDto[]>(
this.redisClient,
this.CARRIERS_CACHE_KEY,
this.CARRIERS_TTL,
async () => {
const carriers = await this.repository.findAllCarriers();
return carriers.map(carrier => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
}));
}
);
} catch (error) {
this.logger.error('Erro ao buscar transportadoras', error);
throw new HttpException('Erro ao buscar transportadoras', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* Obter transportadoras por período de data
* @param query - Filtros de data e filial
* @returns Array de CarrierDto
*/
async getCarriersByDate(query: FindCarriersDto): Promise<CarrierDto[]> {
this.logger.log(`Buscando transportadoras por período: ${JSON.stringify(query)}`);
try {
const carriers = await this.repository.findCarriersByDate(query);
return carriers.map(carrier => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
ordersCount: carrier.ordersCount ? Number(carrier.ordersCount) : 0,
}));
} catch (error) {
this.logger.error('Erro ao buscar transportadoras por período', error);
throw new HttpException('Erro ao buscar transportadoras', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* Obter transportadoras de um pedido específico
* @param orderId - ID do pedido
* @returns Array de CarrierDto
*/
async getOrderCarriers(orderId: number): Promise<CarrierDto[]> {
this.logger.log(`Buscando transportadoras do pedido: ${orderId}`);
try {
const carriers = await this.repository.findOrderCarriers(orderId);
return carriers.map(carrier => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
}));
} catch (error) {
this.logger.error('Erro ao buscar transportadoras do pedido', error);
throw new HttpException('Erro ao buscar transportadoras do pedido', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
} }

View File

@@ -0,0 +1,58 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsDateString } from 'class-validator';
export class CarrierDto {
@ApiProperty({
description: 'ID da transportadora',
example: '123'
})
carrierId: string;
@ApiProperty({
description: 'Nome da transportadora',
example: 'TRANSPORTADORA ABC LTDA'
})
carrierName: string;
@ApiProperty({
description: 'Descrição completa da transportadora (ID - Nome)',
example: '123 - TRANSPORTADORA ABC LTDA'
})
carrierDescription: string;
@ApiProperty({
description: 'Quantidade de pedidos da transportadora no período',
example: 15,
required: false
})
ordersCount?: number;
}
export class FindCarriersDto {
@ApiProperty({
description: 'Data inicial para filtro (formato YYYY-MM-DD)',
example: '2024-01-01',
required: false
})
@IsOptional()
@IsDateString()
dateIni?: string;
@ApiProperty({
description: 'Data final para filtro (formato YYYY-MM-DD)',
example: '2024-12-31',
required: false
})
@IsOptional()
@IsDateString()
dateEnd?: string;
@ApiProperty({
description: 'ID da filial',
example: '1',
required: false
})
@IsOptional()
@IsString()
codfilial?: string;
}

View File

@@ -8,6 +8,11 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path'; import { join } from 'path';
async function bootstrap() { async function bootstrap() {
/**
* Configura timezone para horário brasileiro
*/
process.env.TZ = 'America/Sao_Paulo';
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.use(helmet({ app.use(helmet({
@@ -46,8 +51,8 @@ async function bootstrap() {
app.enableCors({ app.enableCors({
origin: process.env.NODE_ENV === 'production' origin: process.env.NODE_ENV === 'production'
? ['https://seu-dominio.com', 'https://admin.seu-dominio.com'] ? ['https://www.jurunense.com', 'https://*.jurunense.com']
: '*', : ['http://localhost:9602 add '],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
credentials: true, credentials: true,
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],

View File

@@ -1,4 +1,4 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject, HttpStatus } from '@nestjs/common';
import { FindOrdersDto } from '../dto/find-orders.dto'; import { FindOrdersDto } from '../dto/find-orders.dto';
import { InvoiceDto } from '../dto/find-invoice.dto'; import { InvoiceDto } from '../dto/find-invoice.dto';
import { CutItemDto } from '../dto/CutItemDto'; import { CutItemDto } from '../dto/CutItemDto';
@@ -12,14 +12,24 @@ import { OrderDeliveryDto } from '../dto/OrderDeliveryDto';
import { OrderTransferDto } from '../dto/OrderTransferDto'; import { OrderTransferDto } from '../dto/OrderTransferDto';
import { OrderStatusDto } from '../dto/OrderStatusDto'; import { OrderStatusDto } from '../dto/OrderStatusDto';
import { InvoiceCheckDto } from '../dto/invoice-check.dto'; import { InvoiceCheckDto } from '../dto/invoice-check.dto';
import { LeadtimeDto } from '../dto/leadtime.dto';
import { HttpException } from '@nestjs/common/exceptions/http.exception';
import { CarrierDto } from '../../data-consult/dto/carrier.dto';
import { MarkData } from '../interface/markdata';
import { EstLogTransferFilterDto, EstLogTransferResponseDto } from '../dto/estlogtransfer.dto';
@Injectable() @Injectable()
export class OrdersService { export class OrdersService {
private readonly TTL_ORDERS = 60 * 10; // 10 minutos private readonly TTL_ORDERS = 60 * 30; // 30 minutos
private readonly TTL_INVOICE = 60 * 60; // 1 hora private readonly TTL_INVOICE = 60 * 60; // 1 hora
private readonly TTL_ITENS = 60 * 10; // 10 minutos private readonly TTL_ITENS = 60 * 10; // 10 minutos
private readonly TTL_LEADTIME = 60 * 360; // 6 horas
private readonly TTL_DELIVERIES = 60 * 10; // 10 minutos
private readonly TTL_TRANSFER = 60 * 15; // 15 minutos
private readonly TTL_STATUS = 60 * 5; // 5 minutos
private readonly TTL_CARRIERS = 60 * 20; // 20 minutos
private readonly TTL_MARKS = 60 * 25; // 25 minutos
constructor( constructor(
private readonly ordersRepository: OrdersRepository, private readonly ordersRepository: OrdersRepository,
@@ -31,7 +41,6 @@ export class OrdersService {
*/ */
async findOrders(query: FindOrdersDto) { async findOrders(query: FindOrdersDto) {
const key = `orders:query:${this.hashObject(query)}`; const key = `orders:query:${this.hashObject(query)}`;
return getOrSetCache( return getOrSetCache(
this.redisClient, this.redisClient,
key, key,
@@ -40,6 +49,65 @@ export class OrdersService {
); );
} }
/**
* Buscar pedidos por data de entrega com cache
*/
async findOrdersByDeliveryDate(query: any) {
const key = `orders:delivery:${this.hashObject(query)}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_ORDERS,
() => this.ordersRepository.findOrdersByDeliveryDate(query),
);
}
/**
* Buscar pedidos com resultados de fechamento de caixa
*/
async findOrdersWithCheckout(query: FindOrdersDto) {
const key = `orders:checkout:${this.hashObject(query)}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_ORDERS,
async () => {
// Primeiro obtém a lista de pedidos
const orders = await this.findOrders(query);
// Para cada pedido, busca o fechamento de caixa
const results = await Promise.all(
orders.map(async order => {
try {
const checkout = await this.ordersRepository.findOrderWithCheckoutByOrder(
Number(order.orderId),
);
return { ...order, checkout };
} catch {
return { ...order, checkout: null };
}
}),
);
return results;
}
);
}
async getOrderCheckout(orderId: number) {
const key = `orders:checkout:${orderId}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_ORDERS,
async () => {
const result = await this.ordersRepository.findOrderWithCheckoutByOrder(orderId);
if (!result) {
throw new HttpException('Nenhum fechamento encontrado', HttpStatus.NOT_FOUND);
}
return result;
}
);
}
/** /**
* Buscar nota fiscal por chave NFe com cache * Buscar nota fiscal por chave NFe com cache
*/ */
@@ -93,7 +161,27 @@ export class OrdersService {
}); });
} }
/**
* Buscar entregas do pedido com cache
*/
async getOrderDeliveries(
orderId: string,
query: { createDateIni: string; createDateEnd: string },
): Promise<OrderDeliveryDto[]> {
const key = `orders:deliveries:${orderId}:${query.createDateIni}:${query.createDateEnd}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_DELIVERIES,
() => this.ordersRepository.getOrderDeliveries(orderId, query),
);
}
async getCutItens(orderId: string): Promise<CutItemDto[]> { async getCutItens(orderId: string): Promise<CutItemDto[]> {
const key = `orders:cutitens:${orderId}`;
return getOrSetCache(this.redisClient, key, this.TTL_ITENS, async () => {
const itens = await this.ordersRepository.getCutItens(orderId); const itens = await this.ordersRepository.getCutItens(orderId);
return itens.map(item => ({ return itens.map(item => ({
@@ -105,19 +193,73 @@ export class OrdersService {
cutQuantity: Number(item.cutQuantity), cutQuantity: Number(item.cutQuantity),
separedQuantity: Number(item.separedQuantity), separedQuantity: Number(item.separedQuantity),
})); }));
});
} }
async getOrderDelivery(orderId: string): Promise<OrderDeliveryDto> { async getOrderDelivery(orderId: string): Promise<OrderDeliveryDto> {
return this.ordersRepository.getOrderDelivery(orderId); const key = `orders:delivery:${orderId}`;
return getOrSetCache(this.redisClient, key, this.TTL_DELIVERIES, () =>
this.ordersRepository.getOrderDelivery(orderId),
);
}
/**
* Buscar leadtime do pedido com cache
*/
async getLeadtime(orderId: string): Promise<LeadtimeDto[]> {
const key = `orders:leadtime:${orderId}`;
return getOrSetCache(
this.redisClient,
key,
this.TTL_LEADTIME,
() => this.ordersRepository.getLeadtimeWMS(orderId)
);
} }
async getTransfer(orderId: number): Promise<OrderTransferDto[] | null> { async getTransfer(orderId: number): Promise<OrderTransferDto[] | null> {
return this.ordersRepository.getTransfer(orderId); const key = `orders:transfer:${orderId}`;
return getOrSetCache(this.redisClient, key, this.TTL_TRANSFER, () =>
this.ordersRepository.getTransfer(orderId),
);
}
/**
* Buscar log de transferência por ID do pedido com cache
*/
async getTransferLog(
orderId: number,
filters?: EstLogTransferFilterDto
): Promise<EstLogTransferResponseDto[] | null> {
const key = `orders:transfer-log:${orderId}:${this.hashObject(filters || {})}`;
return getOrSetCache(this.redisClient, key, this.TTL_TRANSFER, () =>
this.ordersRepository.estlogtransfer(orderId, filters),
);
}
/**
* Buscar logs de transferência com filtros (sem especificar pedido específico)
*/
async getTransferLogs(
filters?: EstLogTransferFilterDto
): Promise<EstLogTransferResponseDto[] | null> {
const key = `orders:transfer-logs:${this.hashObject(filters || {})}`;
return getOrSetCache(this.redisClient, key, this.TTL_TRANSFER, () =>
this.ordersRepository.estlogtransfers(filters),
);
} }
async getStatusOrder(orderId: number): Promise<OrderStatusDto[] | null> { async getStatusOrder(orderId: number): Promise<OrderStatusDto[] | null> {
return this.ordersRepository.getStatusOrder(orderId); const key = `orders:status:${orderId}`;
return getOrSetCache(this.redisClient, key, this.TTL_STATUS, () =>
this.ordersRepository.getStatusOrder(orderId),
);
} }
/** /**
* Utilitário para gerar hash MD5 de objetos * Utilitário para gerar hash MD5 de objetos
*/ */
@@ -126,9 +268,62 @@ export class OrdersService {
return createHash('md5').update(str).digest('hex'); return createHash('md5').update(str).digest('hex');
} }
async createInvoiceCheck(invoice: InvoiceCheckDto): Promise<{ message: string }> { async createInvoiceCheck(invoice: InvoiceCheckDto): Promise<{ message: string }> {
// Não usa cache para operações de escrita
return this.ordersRepository.createInvoiceCheck(invoice); return this.ordersRepository.createInvoiceCheck(invoice);
} }
/**
* Buscar transportadoras do pedido com cache
*/
async getOrderCarriers(orderId: number): Promise<CarrierDto[]> {
const key = `orders:carriers:${orderId}`;
return getOrSetCache(this.redisClient, key, this.TTL_CARRIERS, async () => {
const carriers = await this.ordersRepository.getOrderCarriers(orderId);
return carriers.map(carrier => ({
carrierId: carrier.carrierId?.toString() || '',
carrierName: carrier.carrierName || '',
carrierDescription: carrier.carrierDescription || '',
}));
});
}
/**
* Buscar marca por ID com cache
*/
async findOrderByMark(orderId: number): Promise<MarkData> {
const key = `orders:mark:${orderId}`;
return getOrSetCache(this.redisClient, key, this.TTL_MARKS, async () => {
const result = await this.ordersRepository.findorderbymark(orderId);
if (!result) {
throw new HttpException('Marca não encontrada', HttpStatus.NOT_FOUND);
}
return result;
});
}
/**
* Buscar todas as marcas disponíveis com cache
*/
async getAllMarks(): Promise<MarkData[]> {
const key = 'orders:marks:all';
return getOrSetCache(this.redisClient, key, this.TTL_MARKS, async () => {
return await this.ordersRepository.getAllMarks();
});
}
/**
* Buscar marcas por nome com cache
*/
async getMarksByName(markName: string): Promise<MarkData[]> {
const key = `orders:marks:name:${markName}`;
return getOrSetCache(this.redisClient, key, this.TTL_MARKS, async () => {
return await this.ordersRepository.getMarksByName(markName);
});
}
} }

View File

@@ -11,39 +11,96 @@ import {
ValidationPipe, ValidationPipe,
HttpException, HttpException,
HttpStatus, HttpStatus,
DefaultValuePipe,
ParseBoolPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags, ApiQuery, ApiParam, ApiResponse } from '@nestjs/swagger';
import { ResponseInterceptor } from '../../common/response.interceptor'; import { ResponseInterceptor } from '../../common/response.interceptor';
import { OrdersService } from '../application/orders.service'; import { OrdersService } from '../application/orders.service';
import { FindOrdersDto } from '../dto/find-orders.dto'; import { FindOrdersDto } from '../dto/find-orders.dto';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; import { FindOrdersByDeliveryDateDto } from '../dto/find-orders-by-delivery-date.dto';
import { JwtAuthGuard, } from 'src/auth/guards/jwt-auth.guard';
import { InvoiceDto } from '../dto/find-invoice.dto'; import { InvoiceDto } from '../dto/find-invoice.dto';
import { OrderItemDto } from "../dto/OrderItemDto"; import { OrderItemDto } from "../dto/OrderItemDto";
import { LeadtimeDto } from '../dto/leadtime.dto';
import { CutItemDto } from '../dto/CutItemDto'; import { CutItemDto } from '../dto/CutItemDto';
import { OrderDeliveryDto } from '../dto/OrderDeliveryDto'; import { OrderDeliveryDto } from '../dto/OrderDeliveryDto';
import { OrderTransferDto } from '../dto/OrderTransferDto'; import { OrderTransferDto } from '../dto/OrderTransferDto';
import { OrderStatusDto } from '../dto/OrderStatusDto'; import { OrderStatusDto } from '../dto/OrderStatusDto';
import { InvoiceCheckDto } from '../dto/invoice-check.dto'; import { InvoiceCheckDto } from '../dto/invoice-check.dto';
import { ParseIntPipe } from '@nestjs/common/pipes/parse-int.pipe';
import { CarrierDto } from 'src/data-consult/dto/carrier.dto';
import { OrderResponseDto } from '../dto/order-response.dto';
import { MarkResponseDto } from '../dto/mark-response.dto';
import { EstLogTransferResponseDto } from '../dto/estlogtransfer.dto';
@ApiTags('Orders') @ApiTags('Orders')
@ApiBearerAuth() //@ApiBearerAuth()
@UseGuards(JwtAuthGuard) //@UseGuards(JwtAuthGuard)
@UseInterceptors(ResponseInterceptor)
@Controller('api/v1/orders') @Controller('api/v1/orders')
export class OrdersController { export class OrdersController {
constructor(private readonly ordersService: OrdersService) {} constructor(private readonly ordersService: OrdersService) {}
@Get('find') @Get('find')
@ApiOperation({
summary: 'Busca pedidos',
description: 'Busca pedidos com filtros avançados. Suporta filtros por data, cliente, vendedor, status, tipo de entrega e status de transferência.'
})
@ApiQuery({ name: 'includeCheckout', required: false, type: 'boolean', description: 'Incluir dados de checkout' })
@ApiQuery({ name: 'statusTransfer', required: false, type: 'string', description: 'Filtrar por status de transferência (Em Trânsito, Em Separação, Aguardando Separação, Concluída)' })
@ApiQuery({ name: 'markId', required: false, type: 'number', description: 'ID da marca para filtrar pedidos' })
@ApiQuery({ name: 'markName', required: false, type: 'string', description: 'Nome da marca para filtrar pedidos (busca parcial)' })
@ApiQuery({ name: 'hasPreBox', required: false, type: 'boolean', description: 'Filtrar pedidos que tenham registros na tabela de transfer log' })
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
findOrders(@Query() query: FindOrdersDto) { @ApiResponse({ status: 200, description: 'Lista de pedidos retornada com sucesso', type: [OrderResponseDto] })
findOrders(
@Query() query: FindOrdersDto,
@Query('includeCheckout', new DefaultValuePipe(false), ParseBoolPipe)
includeCheckout: boolean,
) {
if (includeCheckout) {
return this.ordersService.findOrdersWithCheckout(query);
}
return this.ordersService.findOrders(query); return this.ordersService.findOrders(query);
} }
@Get('find-by-delivery-date')
@ApiOperation({
summary: 'Busca pedidos por data de entrega',
description: 'Busca pedidos filtrados por data de entrega. Suporta filtros adicionais como status de transferência, cliente, vendedor, etc.'
})
@ApiQuery({ name: 'statusTransfer', required: false, type: 'string', description: 'Filtrar por status de transferência (Em Trânsito, Em Separação, Aguardando Separação, Concluída)' })
@ApiQuery({ name: 'markId', required: false, type: 'number', description: 'ID da marca para filtrar pedidos' })
@ApiQuery({ name: 'markName', required: false, type: 'string', description: 'Nome da marca para filtrar pedidos (busca parcial)' })
@ApiQuery({ name: 'hasPreBox', required: false, type: 'boolean', description: 'Filtrar pedidos que tenham registros na tabela de transfer log' })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Lista de pedidos por data de entrega retornada com sucesso', type: [OrderResponseDto] })
findOrdersByDeliveryDate(
@Query() query: FindOrdersByDeliveryDateDto,
) {
return this.ordersService.findOrdersByDeliveryDate(query);
}
@Get(':orderId/checkout')
@ApiOperation({ summary: 'Busca fechamento de caixa para um pedido' })
@ApiParam({ name: 'orderId', example: 236001388 })
@UsePipes(new ValidationPipe({ transform: true }))
getOrderCheckout(
@Param('orderId', ParseIntPipe) orderId: number,
) {
return this.ordersService.getOrderCheckout(orderId);
}
@Get('invoice/:chavenfe') @Get('invoice/:chavenfe')
@ApiParam({
name: 'chavenfe',
required: true,
description: 'Chave da Nota Fiscal (44 dígitos)',
example: '35191234567890000123550010000000011000000010',
})
@ApiOperation({ summary: 'Busca NF pela chave' }) @ApiOperation({ summary: 'Busca NF pela chave' })
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async getInvoice(@Param('chavenfe') chavenfe: string): Promise<InvoiceDto> { async getInvoice(@Param('chavenfe') chavenfe: string): Promise<InvoiceDto> {
@@ -56,12 +113,14 @@ export class OrdersController {
); );
} }
} }
@Get('itens/:orderId') @Get('itens/:orderId')
@ApiOperation({ summary: 'Busca PELO numero do pedido' }) @ApiOperation({ summary: 'Busca PELO numero do pedido' })
@ApiParam({ name: 'orderId', example: '236001388' })
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async getItens(@Param('orderId') orderId: string): Promise<OrderItemDto[]> { async getItens(@Param('orderId', ParseIntPipe) orderId: number): Promise<OrderItemDto[]> {
try { try {
return await this.ordersService.getItens(orderId); return await this.ordersService.getItens(orderId.toString());
} catch (error) { } catch (error) {
throw new HttpException( throw new HttpException(
error.message || 'Erro ao buscar itens do pedido', error.message || 'Erro ao buscar itens do pedido',
@@ -71,10 +130,11 @@ export class OrdersController {
} }
@Get('cut-itens/:orderId') @Get('cut-itens/:orderId')
@ApiOperation({ summary: 'Busca itens cortados do pedido' }) @ApiOperation({ summary: 'Busca itens cortados do pedido' })
@ApiParam({ name: 'orderId', example: '236001388' })
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async getCutItens(@Param('orderId') orderId: string): Promise<CutItemDto[]> { async getCutItens(@Param('orderId', ParseIntPipe) orderId: number): Promise<CutItemDto[]> {
try { try {
return await this.ordersService.getCutItens(orderId); return await this.ordersService.getCutItens(orderId.toString());
} catch (error) { } catch (error) {
throw new HttpException( throw new HttpException(
error.message || 'Erro ao buscar itens cortados', error.message || 'Erro ao buscar itens cortados',
@@ -85,10 +145,11 @@ export class OrdersController {
@Get('delivery/:orderId') @Get('delivery/:orderId')
@ApiOperation({ summary: 'Busca dados de entrega do pedido' }) @ApiOperation({ summary: 'Busca dados de entrega do pedido' })
@ApiParam({ name: 'orderId', example: '236001388' })
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async getOrderDelivery(@Param('orderId') orderId: string): Promise<OrderDeliveryDto | null> { async getOrderDelivery(@Param('orderId', ParseIntPipe) orderId: number): Promise<OrderDeliveryDto | null> {
try { try {
return await this.ordersService.getOrderDelivery(orderId); return await this.ordersService.getOrderDelivery(orderId.toString());
} catch (error) { } catch (error) {
throw new HttpException( throw new HttpException(
error.message || 'Erro ao buscar dados de entrega', error.message || 'Erro ao buscar dados de entrega',
@@ -99,8 +160,9 @@ export class OrdersController {
@Get('transfer/:orderId') @Get('transfer/:orderId')
@ApiOperation({ summary: 'Consulta pedidos de transferência' }) @ApiOperation({ summary: 'Consulta pedidos de transferência' })
@ApiParam({ name: 'orderId', example: 236001388 })
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async getTransfer(@Param('orderId') orderId: number): Promise<OrderTransferDto[] | null> { async getTransfer(@Param('orderId', ParseIntPipe) orderId: number): Promise<OrderTransferDto[] | null> {
try { try {
return await this.ordersService.getTransfer(orderId); return await this.ordersService.getTransfer(orderId);
} catch (error) { } catch (error) {
@@ -113,8 +175,9 @@ async getTransfer(@Param('orderId') orderId: number): Promise<OrderTransferDto[]
@Get('status/:orderId') @Get('status/:orderId')
@ApiOperation({ summary: 'Consulta status do pedido' }) @ApiOperation({ summary: 'Consulta status do pedido' })
@ApiParam({ name: 'orderId', example: 236001388 })
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async getStatusOrder(@Param('orderId') orderId: number): Promise<OrderStatusDto[] | null> { async getStatusOrder(@Param('orderId', ParseIntPipe) orderId: number): Promise<OrderStatusDto[] | null> {
try { try {
return await this.ordersService.getStatusOrder(orderId); return await this.ordersService.getStatusOrder(orderId);
} catch (error) { } catch (error) {
@@ -125,6 +188,43 @@ async getStatusOrder(@Param('orderId') orderId: number): Promise<OrderStatusDto[
} }
} }
@Get(':orderId/deliveries')
@ApiOperation({ summary: 'Consulta entregas do pedido' })
@ApiParam({ name: 'orderId', example: '236001388' })
@ApiQuery({ name: 'createDateIni', required: false, description: 'Data inicial para filtro (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'createDateEnd', required: false, description: 'Data final para filtro (formato YYYY-MM-DD)' })
async getOrderDeliveries(
@Param('orderId', ParseIntPipe) orderId: number,
@Query('createDateIni') createDateIni?: string,
@Query('createDateEnd') createDateEnd?: string,
): Promise<OrderDeliveryDto[]> {
// Definir datas padrão caso não sejam fornecidas
const defaultDateIni = createDateIni || new Date(new Date().setDate(new Date().getDate() - 30)).toISOString().split('T')[0];
const defaultDateEnd = createDateEnd || new Date().toISOString().split('T')[0];
return this.ordersService.getOrderDeliveries(orderId.toString(), {
createDateIni: defaultDateIni,
createDateEnd: defaultDateEnd,
});
}
@Get('leadtime/:orderId')
@ApiOperation({ summary: 'Consulta leadtime do pedido' })
@ApiParam({ name: 'orderId', example: '236001388' })
@UsePipes(new ValidationPipe({ transform: true }))
async getLeadtime(@Param('orderId', ParseIntPipe) orderId: number): Promise<LeadtimeDto[]> {
try {
return await this.ordersService.getLeadtime(orderId.toString());
} catch (error) {
throw new HttpException(
error.message || 'Erro ao buscar leadtime do pedido',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post('invoice/check') @Post('invoice/check')
@ApiOperation({ summary: 'Cria conferência de nota fiscal' }) @ApiOperation({ summary: 'Cria conferência de nota fiscal' })
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
@@ -138,4 +238,146 @@ async createInvoiceCheck(@Body() invoice: InvoiceCheckDto): Promise<{ message: s
); );
} }
} }
@Get('carriers/:orderId')
@ApiOperation({ summary: 'Busca transportadoras do pedido' })
@ApiParam({ name: 'orderId', example: 236001388 })
@UsePipes(new ValidationPipe({ transform: true }))
async getOrderCarriers(@Param('orderId', ParseIntPipe) orderId: number): Promise<CarrierDto[]> {
try {
return await this.ordersService.getOrderCarriers(orderId);
} catch (error) {
throw new HttpException(
error.message || 'Erro ao buscar transportadoras do pedido',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('mark/:orderId')
@ApiOperation({ summary: 'Busca marca por ID do pedido' })
@ApiParam({ name: 'orderId', example: 236001388 })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Marca encontrada com sucesso', type: MarkResponseDto })
@ApiResponse({ status: 404, description: 'Marca não encontrada' })
async findOrderByMark(@Param('orderId', ParseIntPipe) orderId: number): Promise<MarkResponseDto> {
try {
return await this.ordersService.findOrderByMark(orderId);
} catch (error) {
throw new HttpException(
error.message || 'Erro ao buscar marca do pedido',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('marks')
@ApiOperation({ summary: 'Busca todas as marcas disponíveis' })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Lista de marcas retornada com sucesso', type: [MarkResponseDto] })
async getAllMarks(): Promise<MarkResponseDto[]> {
try {
return await this.ordersService.getAllMarks();
} catch (error) {
throw new HttpException(
error.message || 'Erro ao buscar marcas',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('marks/search')
@ApiOperation({ summary: 'Busca marcas por nome' })
@ApiQuery({ name: 'name', required: true, type: 'string', description: 'Nome da marca para buscar' })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Lista de marcas encontradas', type: [MarkResponseDto] })
async getMarksByName(@Query('name') markName: string): Promise<MarkResponseDto[]> {
try {
return await this.ordersService.getMarksByName(markName);
} catch (error) {
throw new HttpException(
error.message || 'Erro ao buscar marcas',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('transfer-log/:orderId')
@ApiOperation({ summary: 'Busca log de transferência por ID do pedido' })
@ApiParam({ name: 'orderId', example: 153068638, description: 'ID do pedido para buscar log de transferência' })
@ApiQuery({ name: 'dttransf', required: false, type: 'string', description: 'Data de transferência (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'codfilial', required: false, type: 'number', description: 'Código da filial de origem' })
@ApiQuery({ name: 'codfilialdest', required: false, type: 'number', description: 'Código da filial de destino' })
@ApiQuery({ name: 'numpedloja', required: false, type: 'number', description: 'Número do pedido da loja' })
@ApiQuery({ name: 'numpedtransf', required: false, type: 'number', description: 'Número do pedido de transferência' })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Log de transferência encontrado com sucesso', type: [EstLogTransferResponseDto] })
@ApiResponse({ status: 400, description: 'OrderId inválido' })
@ApiResponse({ status: 404, description: 'Log de transferência não encontrado' })
async getTransferLog(
@Param('orderId', ParseIntPipe) orderId: number,
@Query('dttransf') dttransf?: string,
@Query('codfilial') codfilial?: number,
@Query('codfilialdest') codfilialdest?: number,
@Query('numpedloja') numpedloja?: number,
@Query('numpedtransf') numpedtransf?: number,
) {
try {
const filters = {
dttransf,
codfilial,
codfilialdest,
numpedloja,
numpedtransf,
};
return await this.ordersService.getTransferLog(orderId, filters);
} catch (error) {
throw new HttpException(
error.message || 'Erro ao buscar log de transferência',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('transfer-log')
@ApiOperation({ summary: 'Busca logs de transferência com filtros' })
@ApiQuery({ name: 'dttransf', required: false, type: 'string', description: 'Data de transferência (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'dttransfIni', required: false, type: 'string', description: 'Data de transferência inicial (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'dttransfEnd', required: false, type: 'string', description: 'Data de transferência final (formato YYYY-MM-DD)' })
@ApiQuery({ name: 'codfilial', required: false, type: 'number', description: 'Código da filial de origem' })
@ApiQuery({ name: 'codfilialdest', required: false, type: 'number', description: 'Código da filial de destino' })
@ApiQuery({ name: 'numpedloja', required: false, type: 'number', description: 'Número do pedido da loja' })
@ApiQuery({ name: 'numpedtransf', required: false, type: 'number', description: 'Número do pedido de transferência' })
@UsePipes(new ValidationPipe({ transform: true }))
@ApiResponse({ status: 200, description: 'Logs de transferência encontrados com sucesso', type: [EstLogTransferResponseDto] })
@ApiResponse({ status: 400, description: 'Filtros inválidos' })
async getTransferLogs(
@Query('dttransf') dttransf?: string,
@Query('dttransfIni') dttransfIni?: string,
@Query('dttransfEnd') dttransfEnd?: string,
@Query('codfilial') codfilial?: number,
@Query('codfilialdest') codfilialdest?: number,
@Query('numpedloja') numpedloja?: number,
@Query('numpedtransf') numpedtransf?: number,
) {
try {
const filters = {
dttransf,
dttransfIni,
dttransfEnd,
codfilial,
codfilialdest,
numpedloja,
numpedtransf,
};
return await this.ordersService.getTransferLogs(filters);
} catch (error) {
throw new HttpException(
error.message || 'Erro ao buscar logs de transferência',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
} }

View File

@@ -0,0 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* DTO para dados do emitente da nota fiscal
*/
export class EmitenteDto {
@ApiProperty({
description: 'Código do emitente da nota fiscal',
example: 32,
nullable: true,
})
codEmitente: number | null;
@ApiProperty({
description: 'Matrícula do funcionário emitente',
example: 32,
nullable: true,
})
emitenteMatricula: number | null;
@ApiProperty({
description: 'Nome do funcionário emitente',
example: 'João Silva',
nullable: true,
})
emitenteNome: string | null;
}

View File

@@ -0,0 +1,110 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* DTO para filtros do log de transferência
*/
export class EstLogTransferFilterDto {
@ApiProperty({
description: 'Data de transferência (formato YYYY-MM-DD)',
example: '2024-02-08',
required: false,
})
dttransf?: string;
@ApiProperty({
description: 'Data de transferência inicial (formato YYYY-MM-DD)',
example: '2024-02-01',
required: false,
})
dttransfIni?: string;
@ApiProperty({
description: 'Data de transferência final (formato YYYY-MM-DD)',
example: '2024-02-15',
required: false,
})
dttransfEnd?: string;
@ApiProperty({
description: 'Código da filial de origem',
example: 6,
required: false,
})
codfilial?: number;
@ApiProperty({
description: 'Código da filial de destino',
example: 4,
required: false,
})
codfilialdest?: number;
@ApiProperty({
description: 'Número do pedido da loja',
example: 153068638,
required: false,
})
numpedloja?: number;
@ApiProperty({
description: 'Número do pedido de transferência',
example: 153068637,
required: false,
})
numpedtransf?: number;
}
/**
* DTO para resposta do log de transferência
*/
export class EstLogTransferResponseDto {
@ApiProperty({
description: 'Data de transferência',
example: '2024-02-08T03:00:00.000Z',
})
DTTRANSF: string;
@ApiProperty({
description: 'Código da filial de origem',
example: 6,
})
CODFILIAL: number;
@ApiProperty({
description: 'Código da filial de destino',
example: 4,
})
CODFILIALDEST: number;
@ApiProperty({
description: 'Número do pedido da loja',
example: 153068638,
})
NUMPEDLOJA: number;
@ApiProperty({
description: 'Número do pedido de transferência',
example: 153068637,
})
NUMPEDTRANSF: number;
@ApiProperty({
description: 'Código do funcionário que fez a transferência',
example: 158,
})
CODFUNCTRANSF: number;
@ApiProperty({
description: 'Número do pedido de recebimento de transferência',
example: null,
nullable: true,
})
NUMPEDRCATRANSF: number | null;
@ApiProperty({
description: 'Status da transferência',
example: 'Em Trânsito',
nullable: true,
})
statusTransfer: string | null;
}

View File

@@ -0,0 +1,113 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsOptional,
IsDateString,
IsString,
IsNumber,
IsIn,
IsBoolean
} from 'class-validator';
/**
* DTO para buscar pedidos por data de entrega
*/
export class FindOrdersByDeliveryDateDto {
@IsOptional()
@IsDateString()
@ApiPropertyOptional({
description: 'Data de entrega inicial (formato: YYYY-MM-DD)',
example: '2024-01-01'
})
deliveryDateIni?: string;
@IsOptional()
@IsDateString()
@ApiPropertyOptional({
description: 'Data de entrega final (formato: YYYY-MM-DD)',
example: '2024-12-31'
})
deliveryDateEnd?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Código da filial',
example: '01'
})
codfilial?: string;
@IsOptional()
@IsNumber()
@ApiPropertyOptional({
description: 'ID do vendedor',
example: 123
})
sellerId?: number;
@IsOptional()
@IsNumber()
@ApiPropertyOptional({
description: 'ID do cliente',
example: 456
})
customerId?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Tipo de entrega (EN, EF, RP, RI)',
example: 'EN'
})
deliveryType?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Status do pedido (L, P, B, M, F)',
example: 'L'
})
status?: string;
@IsOptional()
@IsNumber()
@ApiPropertyOptional({
description: 'ID do pedido específico',
example: 236001388
})
orderId?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Filtrar por status de transferência',
example: 'Em Trânsito,Em Separação,Aguardando Separação,Concluída',
enum: ['Em Trânsito', 'Em Separação', 'Aguardando Separação', 'Concluída']
})
statusTransfer?: string;
@IsOptional()
@IsNumber()
@ApiPropertyOptional({
description: 'ID da marca para filtrar pedidos',
example: 1
})
markId?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Nome da marca para filtrar pedidos',
example: 'Nike'
})
markName?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
@ApiPropertyOptional({
description: 'Filtrar pedidos que tenham registros na tabela de transfer log',
example: true
})
hasPreBox?: boolean;
}

View File

@@ -1,4 +1,8 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ValidateNested } from 'class-validator';
import { CustomerDto } from 'src/data-consult/dto/customer.dto';
import { import {
IsOptional, IsOptional,
@@ -6,6 +10,7 @@ import {
IsNumber, IsNumber,
IsDateString, IsDateString,
IsIn, IsIn,
IsBoolean
} from 'class-validator'; } from 'class-validator';
export class FindOrdersDto { export class FindOrdersDto {
@@ -14,22 +19,68 @@ export class FindOrdersDto {
@ApiPropertyOptional() @ApiPropertyOptional()
codfilial?: string; codfilial?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
includeCheckout?: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()
@ApiPropertyOptional() @ApiPropertyOptional()
filialretira?: string; filialretira?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ description: 'ID da transportadora para filtrar pedidos' })
carrier?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
cnpj?: string;
@IsOptional()
@IsNumber()
@ApiPropertyOptional()
hour?: number;
@IsOptional()
@IsNumber()
@ApiPropertyOptional()
minute?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional()
partnerId?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
customerName?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
stockId?: string;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@ApiPropertyOptional() @ApiPropertyOptional()
sellerId?: number; sellerId?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional()
sellerName?: string;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@ApiPropertyOptional() @ApiPropertyOptional()
customerId?: number; customerId?: number;
@IsOptional() @IsOptional()
@@ -72,6 +123,11 @@ export class FindOrdersDto {
@ApiPropertyOptional() @ApiPropertyOptional()
invoiceDateEnd?: string; invoiceDateEnd?: string;
@IsOptional()
@IsDateString()
@ApiPropertyOptional()
deliveryDate?: string;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -93,7 +149,73 @@ export class FindOrdersDto {
type?: string; type?: string;
@IsOptional() @IsOptional()
@IsIn(['S', 'N']) @Type(() => Boolean)
@IsBoolean()
@ApiPropertyOptional() @ApiPropertyOptional()
onlyPendentingTransfer?: string; onlyPendingTransfer?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Filtrar por status de transferência',
example: 'Em Trânsito,Em Separação,Aguardando Separação,Concluída',
enum: ['Em Trânsito', 'Em Separação', 'Aguardando Separação', 'Concluída']
})
statusTransfer?: string;
@IsOptional()
@IsNumber()
@ApiPropertyOptional({
description: 'ID da marca para filtrar pedidos',
})
markId?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Nome da marca para filtrar pedidos',
})
markName?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
@ApiPropertyOptional({
description: 'Filtrar pedidos que tenham registros na tabela de transfer log',
example: true
})
hasPreBox?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Código da filial de origem da transferência (Pre-Box)',
example: '5'
})
preBoxFilial?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Código da filial de destino da transferência',
example: '6'
})
transferDestFilial?: string;
@IsOptional()
@IsDateString()
@ApiPropertyOptional({
description: 'Data de transferência inicial (formato YYYY-MM-DD)',
example: '2024-01-01'
})
transferDateIni?: string;
@IsOptional()
@IsDateString()
@ApiPropertyOptional({
description: 'Data de transferência final (formato YYYY-MM-DD)',
example: '2024-12-31'
})
transferDateEnd?: string;
} }

View File

@@ -0,0 +1,9 @@
export class LeadtimeDto {
orderId: number;
etapa: number;
descricaoEtapa: string;
data: Date | string;
codigoFuncionario: number | null;
nomeFuncionario: string | null;
numeroPedido: number;
}

View File

@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
export class MarkResponseDto {
@ApiProperty({
description: 'Nome da marca',
example: 'Nike',
})
MARCA: string;
@ApiProperty({
description: 'Código da marca',
example: 1,
})
CODMARCA: number;
@ApiProperty({
description: 'Status ativo da marca (S/N)',
example: 'S',
})
ATIVO: string;
}

View File

@@ -0,0 +1,23 @@
export class OrderDeliveryDto {
storeId: number;
createDate: Date;
orderId: number;
orderIdSale: number | null;
deliveryDate: Date | null;
cnpj: string | null;
customerId: number;
customer: string;
deliveryType: string | null;
quantityItens: number;
status: string;
weight: number;
shipmentId: number;
driverId: number | null;
driverName: string | null;
carPlate: string | null;
carIdentification: string | null;
observation: string | null;
deliveryConfirmationDate: Date | null;
}

View File

@@ -0,0 +1,310 @@
import { ApiProperty } from '@nestjs/swagger';
export class OrderResponseDto {
@ApiProperty({
description: 'Data de criação do pedido',
example: '2024-04-02T10:00:00Z',
})
createDate: Date;
@ApiProperty({
description: 'ID da loja',
example: '001 - Pre-Box (002)',
})
storeId: string;
@ApiProperty({
description: 'ID do pedido',
example: 12345,
})
orderId: number;
@ApiProperty({
description: 'ID do cliente',
example: '12345',
})
customerId: string;
@ApiProperty({
description: 'Nome do cliente',
example: '12345 - João da Silva',
})
customerName: string;
@ApiProperty({
description: 'ID do vendedor',
example: '001',
})
sellerId: string;
@ApiProperty({
description: 'Nome do vendedor',
example: '001 - Maria Santos',
})
sellerName: string;
@ApiProperty({
description: 'Nome da loja',
example: 'Loja Centro',
})
store: string;
@ApiProperty({
description: 'Tipo de entrega',
example: 'Entrega (EN)',
})
deliveryType: string;
@ApiProperty({
description: 'Local de entrega',
example: '001-Centro',
})
deliveryLocal: string;
@ApiProperty({
description: 'Local de entrega principal',
example: '001-Rota Centro',
})
masterDeliveryLocal: string;
@ApiProperty({
description: 'Tipo do pedido',
example: 'TV8 - Entrega (EN)',
})
orderType: string;
@ApiProperty({
description: 'Valor total do pedido',
example: 1000.00,
})
amount: number;
@ApiProperty({
description: 'Data de entrega',
example: '2024-04-05T10:00:00Z',
})
deliveryDate: Date;
@ApiProperty({
description: 'Prioridade de entrega',
example: 'Alta',
})
deliveryPriority: string;
@ApiProperty({
description: 'ID do carregamento',
example: 123,
})
shipmentId: number;
@ApiProperty({
description: 'Data de liberação',
example: '2024-04-02T10:00:00Z',
})
releaseDate: Date;
@ApiProperty({
description: 'Usuário de liberação',
example: '001',
})
releaseUser: string;
@ApiProperty({
description: 'Nome do usuário de liberação',
example: '001 - João Silva',
})
releaseUserName: string;
@ApiProperty({
description: 'Data de saída do carregamento',
example: '2024-04-03T10:00:00Z',
})
shipmentDate: Date;
@ApiProperty({
description: 'Data de criação do carregamento',
example: '2024-04-02T10:00:00Z',
})
shipmentDateCreate: Date;
@ApiProperty({
description: 'Data de fechamento do carregamento',
example: '2024-04-03T18:00:00Z',
})
shipmentCloseDate: Date;
@ApiProperty({
description: 'ID do plano de pagamento',
example: '001',
})
paymentId: string;
@ApiProperty({
description: 'Nome do plano de pagamento',
example: 'Cartão de Crédito',
})
paymentName: string;
@ApiProperty({
description: 'ID da cobrança',
example: '001',
})
billingId: string;
@ApiProperty({
description: 'Nome da cobrança',
example: '001 - Cartão de Crédito',
})
billingName: string;
@ApiProperty({
description: 'Data de faturamento',
example: '2024-04-02T10:00:00Z',
})
invoiceDate: Date;
@ApiProperty({
description: 'Hora de faturamento',
example: 10,
})
invoiceHour: number;
@ApiProperty({
description: 'Minuto de faturamento',
example: 30,
})
invoiceMinute: number;
@ApiProperty({
description: 'Número da nota fiscal',
example: 123456,
})
invoiceNumber: number;
@ApiProperty({
description: 'Descrição do bloqueio',
example: 'Cliente com restrição',
})
BloqDescription: string;
@ApiProperty({
description: 'Data de confirmação de entrega',
example: '2024-04-05T15:00:00Z',
})
confirmDeliveryDate: Date;
@ApiProperty({
description: 'Peso total',
example: 50.5,
})
totalWeigth: number;
@ApiProperty({
description: 'Processo do pedido',
example: 3,
})
processOrder: number;
@ApiProperty({
description: 'Status do pedido',
example: 'FATURADO',
})
status: string;
@ApiProperty({
description: 'Pagamento',
example: 0,
})
payment: number;
@ApiProperty({
description: 'Motorista',
example: '001 - João Silva',
})
driver: string;
@ApiProperty({
description: 'ID do pedido de venda',
example: 12346,
})
orderSaleId: number;
@ApiProperty({
description: 'Descrição do carro',
example: 'Fiat Fiorino (ABC1234)',
})
carDescription: string;
@ApiProperty({
description: 'Identificação do carro',
example: 'ABC1234',
})
carIdentification: string;
@ApiProperty({
description: 'Transportadora',
example: '001 - Transportadora XYZ',
})
carrier: string;
@ApiProperty({
description: 'Status da transferência',
example: 'Em Trânsito',
nullable: true,
})
statusTransfer: string | null;
@ApiProperty({
description: 'Loja Pre-Box',
example: '002',
})
storePreBox: string;
@ApiProperty({
description: 'Código do emitente da nota fiscal',
example: 32,
nullable: true,
})
codEmitente: number | null;
@ApiProperty({
description: 'Matrícula do funcionário emitente',
example: 32,
nullable: true,
})
emitenteMatricula: number | null;
@ApiProperty({
description: 'Nome do funcionário emitente',
example: 'João Silva',
nullable: true,
})
emitenteNome: string | null;
@ApiProperty({
description: 'Código do funcionário de faturamento',
example: 1336,
nullable: true,
})
fatUserCode: number | null;
@ApiProperty({
description: 'Nome do funcionário de faturamento',
example: 'ADRIANO COSTA DA SILVA',
nullable: true,
})
fatUserName: string | null;
@ApiProperty({
description: 'Descrição completa do funcionário de faturamento',
example: '1336-ADRIANO COSTA DA SILVA',
nullable: true,
})
fatUserDescription: string | null;
@ApiProperty({
description: 'Entrega agendada',
example: 'ENTREGA NORMAL',
})
schedulerDelivery: string;
}

View File

@@ -0,0 +1,20 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ResultModel } from '../../shared/ResultModel';
@Injectable()
export class OrdersResponseInterceptor<T> implements NestInterceptor<T, ResultModel<T>> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<ResultModel<T>> {
return next.handle().pipe(
map((data) => {
return ResultModel.success(data);
}),
);
}
}

View File

@@ -0,0 +1,5 @@
export interface MarkData {
MARCA: string;
CODMARCA: number;
ATIVO: string;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class PartnerDto {
@ApiProperty({ description: 'Identificador do parceiro' })
id: string;
@ApiProperty({ description: 'CPF do parceiro' })
cpf: string;
@ApiProperty({ description: 'Nome do parceiro' })
nome: string;
constructor(partial: Partial<PartnerDto>) {
Object.assign(this, partial);
}
}

View File

@@ -0,0 +1,48 @@
import { ApiTags, ApiOperation, ApiParam, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { Controller, Get, Param } from '@nestjs/common';
import { PartnersService } from './partners.service';
import { PartnerDto } from './dto/partner.dto';
@ApiTags('Parceiros')
@Controller('api/v1/')
export class PartnersController {
constructor(private readonly partnersService: PartnersService) {}
@Get('parceiros/:filter')
@ApiOperation({ summary: 'Busca parceiros por filtro (ID, CPF ou nome)' })
@ApiParam({ name: 'filter', description: 'Filtro de busca (ID, CPF ou nome)' })
@ApiResponse({
status: 200,
description: 'Lista de parceiros encontrados.',
type: PartnerDto,
isArray: true
})
async findPartners(@Param('filter') filter: string): Promise<PartnerDto[]> {
return this.partnersService.findPartners(filter);
}
@Get('parceiros')
@ApiOperation({ summary: 'Lista todos os parceiros' })
@ApiResponse({
status: 200,
description: 'Lista de todos os parceiros.',
type: PartnerDto,
isArray: true
})
async getAllPartners(): Promise<PartnerDto[]> {
return this.partnersService.getAllPartners();
}
@Get('parceiros/id/:id')
@ApiOperation({ summary: 'Busca parceiro por ID específico' })
@ApiParam({ name: 'id', description: 'ID do parceiro' })
@ApiResponse({
status: 200,
description: 'Parceiro encontrado.',
type: PartnerDto
})
async getPartnerById(@Param('id') id: string): Promise<PartnerDto | null> {
return this.partnersService.getPartnerById(id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PartnersController } from './partners.controller';
import { PartnersService } from './partners.service';
import { DatabaseModule } from '../core/database/database.module';
import { RedisModule } from '../core/configs/cache/redis.module';
@Module({
imports: [DatabaseModule, RedisModule],
controllers: [PartnersController],
providers: [PartnersService],
exports: [PartnersService],
})
export class PartnersModule {}

View File

@@ -0,0 +1,168 @@
import { Injectable, Inject } from '@nestjs/common';
import { QueryRunner, DataSource } from 'typeorm';
import { DATA_SOURCE } from '../core/constants';
import { RedisClientToken } from '../core/configs/cache/redis-client.adapter.provider';
import { IRedisClient } from '../core/configs/cache/IRedisClient';
import { getOrSetCache } from '../shared/cache.util';
import { PartnerDto } from './dto/partner.dto';
@Injectable()
export class PartnersService {
private readonly PARTNERS_TTL = 60 * 60 * 12; // 12 horas
private readonly PARTNERS_CACHE_KEY = 'parceiros:search';
constructor(
@Inject(DATA_SOURCE)
private readonly dataSource: DataSource,
@Inject(RedisClientToken)
private readonly redisClient: IRedisClient,
) {}
/**
* Buscar parceiros com cache otimizado
* @param filter - Filtro de busca (ID, CPF ou nome)
* @returns Array de parceiros encontrados
*/
async findPartners(filter: string): Promise<PartnerDto[]> {
const cacheKey = `${this.PARTNERS_CACHE_KEY}:${filter}`;
return getOrSetCache(
this.redisClient,
cacheKey,
this.PARTNERS_TTL,
async () => {
const queryRunner: QueryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
// Primeira tentativa: busca por ID do parceiro
let sql = `SELECT ESTPARCEIRO.ID as "id",
ESTPARCEIRO.ID || ' - ' || ESTPARCEIRO.NOME ||
' ( ' || ESTPARCEIRO.CPF || ' )' as "name",
ESTPARCEIRO.CPF as "cpf"
FROM ESTPARCEIRO
WHERE ESTPARCEIRO.ID = REGEXP_REPLACE('${filter}', '[^0-9]', '')
ORDER BY ESTPARCEIRO.NOME`;
let partners = await queryRunner.manager.query(sql);
// Segunda tentativa: busca por CPF se não encontrou por ID
if (partners.length === 0) {
sql = `SELECT ESTPARCEIRO.ID as "id",
ESTPARCEIRO.ID || ' - ' || ESTPARCEIRO.NOME ||
' ( ' || ESTPARCEIRO.CPF || ' )' as "name",
ESTPARCEIRO.CPF as "cpf"
FROM ESTPARCEIRO
WHERE REGEXP_REPLACE(ESTPARCEIRO.CPF, '[^0-9]', '') = REGEXP_REPLACE('${filter}', '[^0-9]', '')
ORDER BY ESTPARCEIRO.NOME`;
partners = await queryRunner.manager.query(sql);
}
// Terceira tentativa: busca por nome do parceiro se não encontrou por ID ou CPF
if (partners.length === 0) {
sql = `SELECT ESTPARCEIRO.ID as "id",
ESTPARCEIRO.ID || ' - ' || ESTPARCEIRO.NOME ||
' ( ' || ESTPARCEIRO.CPF || ' )' as "name",
ESTPARCEIRO.CPF as "cpf"
FROM ESTPARCEIRO
WHERE ESTPARCEIRO.NOME LIKE '${filter.toUpperCase().replace('@', '%')}%'
ORDER BY ESTPARCEIRO.NOME`;
partners = await queryRunner.manager.query(sql);
}
return partners.map(partner => new PartnerDto({
id: partner.id,
cpf: partner.cpf,
nome: partner.name
}));
} finally {
await queryRunner.release();
}
}
);
}
/**
* Buscar todos os parceiros com cache
* @returns Array de todos os parceiros
*/
async getAllPartners(): Promise<PartnerDto[]> {
const cacheKey = 'parceiros:all';
return getOrSetCache(
this.redisClient,
cacheKey,
this.PARTNERS_TTL,
async () => {
const queryRunner: QueryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT ESTPARCEIRO.ID as "id",
ESTPARCEIRO.ID || ' - ' || ESTPARCEIRO.NOME ||
' ( ' || ESTPARCEIRO.CPF || ' )' as "name",
ESTPARCEIRO.CPF as "cpf"
FROM ESTPARCEIRO
ORDER BY ESTPARCEIRO.NOME`;
const partners = await queryRunner.manager.query(sql);
return partners.map(partner => new PartnerDto({
id: partner.id,
cpf: partner.cpf,
nome: partner.name
}));
} finally {
await queryRunner.release();
}
}
);
}
/**
* Buscar parceiro por ID específico com cache
* @param partnerId - ID do parceiro
* @returns Parceiro encontrado ou null
*/
async getPartnerById(partnerId: string): Promise<PartnerDto | null> {
const cacheKey = `parceiros:id:${partnerId}`;
return getOrSetCache(
this.redisClient,
cacheKey,
this.PARTNERS_TTL,
async () => {
const queryRunner: QueryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
const sql = `SELECT ESTPARCEIRO.ID as "id",
ESTPARCEIRO.ID || ' - ' || ESTPARCEIRO.NOME ||
' ( ' || ESTPARCEIRO.CPF || ' )' as "name",
ESTPARCEIRO.CPF as "cpf"
FROM ESTPARCEIRO
WHERE ESTPARCEIRO.ID = '${partnerId}'`;
const partners = await queryRunner.manager.query(sql);
return partners.length > 0 ? new PartnerDto({
id: partners[0].id,
cpf: partners[0].cpf,
nome: partners[0].name
}) : null;
} finally {
await queryRunner.release();
}
}
);
}
/**
* Limpar cache de parceiros (útil para invalidação)
* @param pattern - Padrão de chaves para limpar (opcional)
*/
async clearPartnersCache(pattern?: string) {
const cachePattern = pattern || 'parceiros:*';
// Nota: Esta funcionalidade requer implementação específica do Redis
// Por enquanto, mantemos a interface para futuras implementações
console.log(`Cache de parceiros seria limpo para o padrão: ${cachePattern}`);
}
}

113
src/shared/date.util.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* Utilitário para manipulação de datas no horário brasileiro
*/
export class DateUtil {
private static readonly BRAZIL_TIMEZONE = 'America/Sao_Paulo';
/**
* Obtém a data atual no horário brasileiro
* @returns Data atual no horário brasileiro
*/
static now(): Date {
return new Date();
}
/**
* Converte uma data para string no formato ISO com timezone brasileiro
* @param date Data a ser convertida
* @returns String da data no formato ISO com timezone brasileiro
*/
static toBrazilISOString(date: Date): string {
return date.toLocaleString('sv-SE', {
timeZone: this.BRAZIL_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).replace(' ', 'T') + '.000Z';
}
/**
* Converte uma data para string formatada no horário brasileiro
* @param date Data a ser convertida
* @param format Formato desejado (padrão: 'dd/MM/yyyy HH:mm:ss')
* @returns String da data formatada no horário brasileiro
*/
static toBrazilString(date: Date, format: string = 'dd/MM/yyyy HH:mm:ss'): string {
const options: Intl.DateTimeFormatOptions = {
timeZone: this.BRAZIL_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
const formatter = new Intl.DateTimeFormat('pt-BR', options);
const parts = formatter.formatToParts(date);
const year = parts.find(part => part.type === 'year')?.value;
const month = parts.find(part => part.type === 'month')?.value;
const day = parts.find(part => part.type === 'day')?.value;
const hour = parts.find(part => part.type === 'hour')?.value;
const minute = parts.find(part => part.type === 'minute')?.value;
const second = parts.find(part => part.type === 'second')?.value;
return format
.replace('dd', day || '')
.replace('MM', month || '')
.replace('yyyy', year || '')
.replace('HH', hour || '')
.replace('mm', minute || '')
.replace('ss', second || '');
}
/**
* Obtém timestamp atual no horário brasileiro
* @returns Timestamp em milissegundos
*/
static nowTimestamp(): number {
return Date.now();
}
/**
* Converte timestamp para data no horário brasileiro
* @param timestamp Timestamp em milissegundos
* @returns Data no horário brasileiro
*/
static fromTimestamp(timestamp: number): Date {
return new Date(timestamp);
}
/**
* Verifica se uma data está no horário de verão brasileiro
* @param date Data a ser verificada
* @returns true se estiver no horário de verão
*/
static isBrazilianDaylightSavingTime(date: Date): boolean {
const year = date.getFullYear();
// Horário de verão no Brasil geralmente vai de outubro a fevereiro
// (regras podem variar, esta é uma implementação simplificada)
const october = new Date(year, 9, 1); // Outubro
const february = new Date(year + 1, 1, 1); // Fevereiro do ano seguinte
return date >= october || date < february;
}
/**
* Obtém o offset do timezone brasileiro em minutos
* @param date Data para calcular o offset
* @returns Offset em minutos (negativo para oeste)
*/
static getBrazilTimezoneOffset(date: Date): number {
const utc = new Date(date.getTime() + (date.getTimezoneOffset() * 60000));
const brazil = new Date(utc.toLocaleString('en-US', { timeZone: this.BRAZIL_TIMEZONE }));
return (utc.getTime() - brazil.getTime()) / 60000;
}
}