diff --git a/src/controllers/rfids-controller.js b/src/controllers/rfids-controller.js new file mode 100644 index 00000000..ea10c759 --- /dev/null +++ b/src/controllers/rfids-controller.js @@ -0,0 +1,371 @@ +import { Op, Sequelize } from 'sequelize'; +import models from '../models/index.js'; + +const { Rfid, TomboFoto, Tombo, Especie, Coletor } = models; + +const getIncludedTomboFoto = rfidJson => ( + rfidJson.TomboFoto || rfidJson.tombos_foto || rfidJson.tombo_foto || rfidJson.TomboFotos +); + +const getIncludedTombo = tomboFotoJson => ( + tomboFotoJson?.Tombo || tomboFotoJson?.tombo +); + +const getNomeCientifico = tombo => ( + tombo?.especie?.nome || tombo?.Especie?.nome || tombo?.nome_cientifico || 'N/A' +); + +const getColetorPrincipal = tombo => ( + tombo?.coletor?.nome || tombo?.Coletor?.nome || 'N/A' +); + +const codificarParaEpcHex = (tomboHcf, codigoBarra) => { + const tomboId = parseInt(tomboHcf, 10); + const match = codigoBarra.match(/([a-zA-Z]+)(\d+)/); + + if (!match) { + throw new Error('Formato de código de barras inválido para gravação RFID.'); + } + + const herbario = match[1]; + const numero = parseInt(match[2], 10); + + // ID Tombo (4 Bytes = 8 chars hex) + const bloco1 = tomboId.toString(16).padStart(8, '0').toUpperCase(); + + // Sigla herbario (4 Bytes = 8 chars hex) + const prefixo = `_${herbario}`.substring(0, 4).padEnd(4, ' '); + let bloco2 = ''; + + for(let i = 0; i < 4; i++) { + bloco2 += prefixo.charCodeAt(i).toString(16).padStart(2, '0').toUpperCase(); + } + + // Numero cog de barras (4 Bytes = 8 chars hex) + const bloco3 = numero.toString(16).padStart(8, '0').toUpperCase(); + + return bloco1 + bloco2 + bloco3; +}; + +const decodificarEpcHex = (epcHex) => { + if (!epcHex || epcHex.length !== 24) return epcHex; + + try { + const hexBloco1 = epcHex.substring(0, 8); + const hexBloco2 = epcHex.substring(8, 16); + const hexBloco3 = epcHex.substring(16, 24); + const tomboId = parseInt(hexBloco1, 16); + let prefixo = ''; + + for(let i = 0; i < 8; i += 2) { + prefixo += String.fromCharCode(parseInt(hexBloco2.substring(i, i + 2), 16)); + } + + prefixo = prefixo.trim(); + + const numero = parseInt(hexBloco3, 16); + const numeroFormatado = String(numero).padStart(9, '0'); + + return `${tomboId}${prefixo}${numeroFormatado}`; + } catch (e) { + return epcHex; + } +}; + +export const iniciarGravacao = async (request, response, next) => { + const { tombo_foto_id } = request.body; + + try { + const foto = await TomboFoto.findByPk(tombo_foto_id); + if (!foto) return response.status(404).json({ erro: 'Foto não encontrada.' }); + + let epcHexParaGravar; + + try { + epcHexParaGravar = codificarParaEpcHex(foto.tombo_hcf, foto.codigo_barra); + } catch (err) { + return response.status(400).json({ erro: err.message }); + } + + let rfid = await Rfid.findOne({ where: { tombo_foto_id } }); + + if (rfid) { + if (rfid.status === 'CONCLUIDO') { + return response.status(409).json({ + erro: 'Tombo já possui uma etiqueta RFID vinculada e concluída.' + }); + } + + rfid.epc = epcHexParaGravar; + rfid.status = 'PENDENTE'; + await rfid.save(); + + } else { + rfid = await Rfid.create({ + tombo_foto_id, + epc: epcHexParaGravar, + status: 'PENDENTE' + }); + } + + return response.status(201).json({ + mensagem: 'Operação iniciada.', + rfid: { + id: rfid.id, + epc: rfid.epc, + status: rfid.status + } + }); + + } catch (error) { + next(error); + } +}; + +export const finalizarGravacao = async (request, response, next) => { + const { id } = request.params; + const { status, tid } = request.body; + + try { + const rfid = await Rfid.findByPk(id); + if (!rfid) return response.status(404).json({ erro: 'Tag não encontrada.' }); + + if (rfid.status === 'CONCLUIDO') { + return response.status(409).json({ + erro: 'Ttag já registrada como CONCLUIDO e não pode ser modificada.' + }); + } + + const statusFinal = status || ''; + + switch (statusFinal) { + case 'FALHA': + rfid.tid = null; + break; + + case 'CONCLUIDO': + if (tid) { + const tagExistente = await Rfid.findOne({ where: { tid } }); + + if (tagExistente && tagExistente.id !== Number(id)) { + return response.status(409).json({ + erro: 'TID já se encontra vinculado a outro tombo no sistema.' + }); + } + + rfid.tid = tid; + } + break; + + default: + return response.status(400).json({ + erro: `Operação negada. Status inválido: '${statusFinal}'.` + }); + } + + rfid.status = statusFinal; + await rfid.save(); + + return response.status(200).json({ + mensagem: 'Operação finalizada.', + rfid + }); + + } catch (error) { + next(error); + } +}; + +export const listagem = async (request, response, next) => { + try { + const limite = parseInt(request.query.limite) || 20; + const pagina = parseInt(request.query.pagina) || 1; + const offset = (pagina - 1) * limite; + + const { tombo_hcf, epc, codigo_barra, status } = request.query; + const whereRfid = {}; + + if (epc) { + whereRfid.epc = { [Op.iLike]: `%${epc}%` }; + } + + if (status) { + whereRfid.status = status; + } + + const whereFoto = {}; + + if (tombo_hcf) { + whereFoto.tombo_hcf = tombo_hcf; + } + + if (codigo_barra) { + whereFoto.codigo_barra = codigo_barra; + } + + const rfids = await Rfid.findAndCountAll({ + limit: limite, + offset, + where: whereRfid, + include: [ + { + model: TomboFoto, + attributes: ['id', 'tombo_hcf', 'codigo_barra', 'caminho_foto'], + where: Object.keys(whereFoto).length > 0 ? whereFoto : undefined, + required: Object.keys(whereFoto).length > 0 + } + ], + order: [['created_at', 'DESC']] + }); + + return response.status(200).json({ + dados: rfids.rows, + meta: { + total: rfids.count, + pagina, + limite + }, + }); + + } catch (error) { + next(error); + } +}; + +export const listarPendentesRfid = async (request, response, next) => { + + try { + const limite = parseInt(request.query.limite) || 10; + const pagina = parseInt(request.query.pagina) || 1; + const offset = (pagina - 1) * limite; + const { q } = request.query; + + const whereCondicao = { + id: { + [Op.notIn]: Sequelize.literal(`(SELECT tombo_foto_id FROM rfids WHERE status = 'CONCLUIDO' AND tombo_foto_id IS NOT NULL)`) + } + }; + + if (q) { + whereCondicao[Op.or] = [ + Sequelize.where(Sequelize.cast(Sequelize.col('tombo_hcf'), 'varchar'), { [Op.iLike]: `%${q}%` }), + Sequelize.where(Sequelize.cast(Sequelize.col('codigo_barra'), 'varchar'), { [Op.iLike]: `%${q}%` }) + ]; + } + + const tombosPendentes = await TomboFoto.findAndCountAll({ + attributes: ['id', 'tombo_hcf', 'codigo_barra', 'caminho_foto'], + where: whereCondicao, + include: [ + { + model: Tombo, + attributes: ['hcf', 'nome_cientifico'], + required: false, + include: [ + { + model: Especie, + as: 'especie', + attributes: ['nome'], + required: false, + }, + { + model: Coletor, + as: 'coletor', + attributes: ['nome'], + required: false, + }, + ], + }, + ], + limit: limite, + offset: offset, + order: [['id', 'DESC']] + }); + + const dados = tombosPendentes.rows.map(tomboFoto => { + const item = tomboFoto.toJSON(); + const tombo = getIncludedTombo(item); + + return { + id: item.id, + tombo_hcf: item.tombo_hcf, + codigo_barra: item.codigo_barra, + caminho_foto: item.caminho_foto, + nome_cientifico: getNomeCientifico(tombo), + coletor_principal: getColetorPrincipal(tombo), + }; + }); + + return response.status(200).json({ + dados, + meta: { + total: tombosPendentes.count, + pagina, + limite + } + }); + + } catch (error) { + next(error); + } +}; + +export const validarEpc = async (request, response, next) => { + const { epc } = request.params; + + try { + const rfid = await Rfid.findOne({ + where: { epc: epc }, + include: [ + { + model: TomboFoto, + include: [ + { + model: Tombo, + include: [ + { + model: Especie, + as: 'especie', + attributes: ['nome'] + }, + { + model: Coletor, + as: 'coletor', + attributes: ['nome'] + } + ] + } + ] + } + ] + }); + + if (!rfid) { + return response.status(404).json({ + valido: false, + mensagem: 'EPC não encontrado.' + }); + } + + const rfidJson = rfid.toJSON(); + const tomboFoto = getIncludedTomboFoto(rfidJson); + const tombo = getIncludedTombo(tomboFoto); + + return response.status(200).json({ + valido: true, + mensagem: 'EPC validado com sucesso.', + dados: { + id_rfid: rfidJson.id, + epc_formatado: rfidJson.epc, + epc: decodificarEpcHex(rfidJson.epc), + status_rfid: rfidJson.status, + tombo_hcf: tombo?.hcf || tomboFoto?.tombo_hcf || 'N/A', + nome_cientifico: getNomeCientifico(tombo), + coletor_principal: getColetorPrincipal(tombo) + } + }); + + } catch (error) { + next(error); + } +}; diff --git a/src/database/migration/20260510165542_criacao_tabela_rfids.ts b/src/database/migration/20260510165542_criacao_tabela_rfids.ts new file mode 100644 index 00000000..fb5fa8c3 --- /dev/null +++ b/src/database/migration/20260510165542_criacao_tabela_rfids.ts @@ -0,0 +1,26 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + await knex.transaction(async trx => { + + await trx.schema.dropTableIfExists('rfids') + + await trx.schema.createTable('rfids', table => { + table.increments('id').primary() + + table.bigInteger('tombo_foto_id').notNullable() + table.foreign('tombo_foto_id') + .references('id') + .inTable('tombos_fotos') + .onDelete('CASCADE') + .onUpdate('CASCADE') + + table.string('epc', 96).notNullable().unique() + table.string('tid', 96).nullable().unique() + + table.string('status', 20).notNullable().defaultTo('PENDENTE') + table.timestamp('created_at').defaultTo(trx.fn.now()) + table.timestamp('updated_at').defaultTo(trx.fn.now()) + }) + }) +} diff --git a/src/models/Rfid.js b/src/models/Rfid.js new file mode 100644 index 00000000..d4528f02 --- /dev/null +++ b/src/models/Rfid.js @@ -0,0 +1,60 @@ +function associate(modelos) { + const { + TomboFoto, + Rfid, + } = modelos; + + Rfid.belongsTo(TomboFoto, { + foreignKey: 'tombo_foto_id' + }); + +} + +export const defaultScope = { + attributes: { + exclude: [ + 'created_at', + 'updated_at', + ], + }, +}; + +export default (Sequelize, DataTypes) => { + const attributes = { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + tombo_foto_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + epc: { + type: DataTypes.STRING(96), + allowNull: false, + }, + tid: { + type: DataTypes.STRING(96), + allowNull: true, + }, + status: { + type: DataTypes.STRING(20), + allowNull: false, + defaultValue: 'PENDENTE', + }, + }; + + const options = { + defaultScope, + tableName: 'rfids', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + }; + + const Model = Sequelize.define('Rfid', attributes, options); + + Model.associate = associate; + return Model; +}; diff --git a/src/models/Tombo.js b/src/models/Tombo.js index 29156fb2..da00a7e7 100644 --- a/src/models/Tombo.js +++ b/src/models/Tombo.js @@ -62,6 +62,7 @@ function associate(modelos) { }); Tombo.belongsTo(Coletor, { + as: 'coletor', foreignKey: 'coletor_id', }); diff --git a/src/models/TomboFoto.js b/src/models/TomboFoto.js index 5d4b8043..c6363d46 100644 --- a/src/models/TomboFoto.js +++ b/src/models/TomboFoto.js @@ -2,11 +2,16 @@ function associate(modelos) { const { Tombo, TomboFoto, + Rfid } = modelos; TomboFoto.belongsTo(Tombo, { foreignKey: 'tombo_hcf', }); + + TomboFoto.hasMany(Rfid, { + foreignKey: 'tombo_foto_id', as: 'rfids' + }); } export const defaultScope = { diff --git a/src/routes/rfids.js b/src/routes/rfids.js new file mode 100644 index 00000000..8c0c273b --- /dev/null +++ b/src/routes/rfids.js @@ -0,0 +1,107 @@ +import * as RfidsController from '../controllers/rfids-controller.js'; + +/** + * @swagger + * tags: + * - name: RFIDs + * description: Operações de integração com RFID + */ +export default app => { + + /** + * @swagger + * /rfids/iniciar-gravacao: + * post: + * summary: Inicia o fluxo de gravação + * tags: [RFIDs] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * tombo_foto_id: + * type: integer + * required: + * - tombo_foto_id + * example: + * tombo_foto_id: 15 + * responses: + * '201': + * description: Registro criado com status PENDENTE e EPC gerado. + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/rfids/iniciar-gravacao').post(RfidsController.iniciarGravacao); + + /** + * @swagger + * /rfids/finalizar-gravacao/{id}: + * put: + * summary: Finaliza o fluxo atualizando o status do RFID + * tags: [RFIDs] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID do registro RFID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [CONCLUIDO, FALHA] + * tid: + * type: string + * example: + * status: "CONCLUIDO" + * tid: "E200001B4413" + * responses: + * '200': + * description: RFID atualizado com sucesso. + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/rfids/finalizar-gravacao/:id').put(RfidsController.finalizarGravacao); + + /** + * @swagger + * /rfids: + * get: + * summary: Lista o status de todas as etiquetas e tombos atrelados + * tags: [RFIDs] + * parameters: + * - in: query + * name: pagina + * schema: + * type: integer + * description: Número da página para paginação + * - in: query + * name: limite + * schema: + * type: integer + * description: Quantidade de itens por página + * responses: + * '200': + * description: Lista retornada com sucesso + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/rfids').get(RfidsController.listagem); + + app.route('/rfids/tombos-pendentes').get(RfidsController.listarPendentesRfid); + + app.route('/rfids/validar/:epc').get(RfidsController.validarEpc); + +};