152 lines
23 KiB
Python
152 lines
23 KiB
Python
"""Extend the sole audit matrix; preserve every previous source/criterion/state."""
|
||
import collections
|
||
import csv
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||
csv.field_size_limit(5000000)
|
||
matrix = DOC / 'matriz-requisitos.csv'
|
||
rows = list(csv.DictReader(matrix.open(encoding='utf-8-sig')))
|
||
headers = list(rows[0])
|
||
protected = ['ID', 'Pedido/trecho', 'Fonte/ocorrências', 'Estado', 'Critério de aceitação', 'Versões divergentes', 'IDs das linhas de origem', 'Quantidade de linhas de origem', 'Critérios e versões por origem (JSON)']
|
||
before = {r['ID']: {k: r[k] for k in protected} for r in rows if not r['ID'].startswith('AUD-LEG-')}
|
||
tag = '[Legado QA 10/10/2026] '
|
||
evidence = 'legado-piloto-qa.md; legacy-content-http-qa.json: recursos6/5 públicos, hashes verificados, pesquisa, login/MFA21, criação/continuação, logout/nova sessão, recuperação, replay único e isolamento. Interface não observada.'
|
||
items = [
|
||
('01', 'Reconciliar páginas, imagens, anexos, categorias e URLs do legado com biblioteca/importações e auditoria', '92 URLs recolhidos,50 versões de artigos e2.826 referências inventariados;30.170 entradas de sitemap de outro domínio preservadas. Dois recursos únicos importados/repetição sem duplicação.', 'Cobertura histórica/paginação/destinos externos e restantes versões por reconciliar. Imagem histórica404 identificada.', 'Cada conteúdo/versão tem identidade, origem, autoria/datas conhecidas, hash, duplicados/conflitos e destino; inventário cobre as fontes acessíveis, sem omissão das não importadas.'),
|
||
('02', 'Adaptar cores, tipografia, imagens e padrões reais do legado aos componentes existentes, com acessibilidade e móvel', 'CSS/chunkwelcome e fotografia reais recuperados; Noto Sans/Serif locais, paleta vermelho/verde/fundo claro, cartões e foco/labels preparados no buildQA.', 'Render móvel/desktop, teclado e contraste efetivo no navegador pendentes.', 'Comparar com referências reais e demonstrar leitura/ações em desktop/móvel/teclado; sem imagens quebradas ou controlo inacessível.'),
|
||
('03', 'Piloto página de agronegócio → artigo → localizações/oportunidades → iniciar/continuar ideia no dossier', 'Rotas públicas e fontes da biblioteca; uma oportunidade publicada no setor; links da carteira/mapas; criação e continuação com referência ao artigo no payload cifrado e evento existentes.', 'Percurso visual completo pendente; seis referências territoriais não delimitadas/associadas por inferência.', 'Percorrer as ações na interface, guardar, recuperar noutra sessão, continuar e voltar ao conteúdo; dados/contextos separados e próxima ação existente.'),
|
||
('04', 'Consulta pública sem login; autenticação apenas para guardar/acompanhar dados pessoais', 'Artigo, imagem, biblioteca e pesquisa respondem sem login; início/associação ao dossier requer sessão verificada. Outra conta403, contexto privado ausente em leitura pública/alheia.', 'Verificação visual dos pontos de autenticação e sessão pendente; C37 permanece aberto.', 'Leitor anónimo consulta tudo o que foi autorizado; apenas gravação privada pede autenticação; nenhum dado do dossier vaza para anónimos/outras contas.'),
|
||
('05', 'Fonte única associada a setores, territórios e etapas com fundamento; artigo não vira oportunidade concreta', 'Documento único6 e imagem5; três links com fundamento: tema, thumbnail e apoio editorialS01. Relações de oportunidades pelo setor já cadastrado; zero oportunidade ou geometria criada pelo artigo.', 'Correspondências territoriais concretas e ampliação por etapa/setor ainda pendentes; não inventar ligações.', 'Mesma fonte/versionamento reutilizados; relações têm citação/âmbito; cadastros reais e artigos distintos; ausência de relação é apresentada explicitamente.'),
|
||
('06', 'Preservar contexto ao entrar/regressar ao percurso, URLs existentes e correspondências de migração', 'Mantido /agronegocio; /explorar/agronegocio novo. Referência comID/hash/versão/ator/data e retorno autorizado ao mesmo dossier; replay sem duplicação; cinco fases/19etapas conservadas.', 'Mapa final de migração de outros URLs e demonstração visual de retorno pendentes.', 'URL antigo recupera a mesma fonte; login/retorno preservam a escolha; nova sessão recupera; nenhuma mistura entre projetos nem conversão de roteiro legado.'),
|
||
('07', 'Verificar imagens, anexos, links, pesquisa, permissões e duplicações, registando aceitação visual separadamente', 'Dois originais com ClamD real/hash; links dos consumidores200; pesquisahit/miss; permissões; repetição do importador sem novas linhas e associação idempotente.', 'Visual pendente; anexos/referências de restantes conteúdos e erro404 do legado por resolver individualmente.', 'Cada consumidor apresenta ficheiro/link/resultado íntegro; importação/pesquisa/recuperação/permissões verificadas e resultados visuais registados por ação.')
|
||
]
|
||
rows = [r for r in rows if not r['ID'].startswith('AUD-LEG-')]
|
||
for suffix, request, implementation, gap, acceptance in items:
|
||
ident = 'AUD-LEG-' + suffix
|
||
origin = 'USR-LEG-20261010-' + suffix
|
||
row = {k: '' for k in headers}
|
||
row.update({'ID': ident, 'Pedido/trecho': request, 'Fonte/ocorrências': 'Pedido direto do responsável,10/10/2026, integração do legado emQA §' + str(int(suffix)) + '; https://investemoz.tempo.co.mz/; legado-piloto-qa.md',
|
||
'Módulo': 'biblioteca / conteúdo público / percurso do investidor', 'Rotas': '/explorar/agronegocio; /agronegocio; /documentos/6; /investimentos/a-partir-de/agronegocio; /investimentos/{id}/conteudos',
|
||
'Implementação encontrada': implementation, 'Disponibilidade ativa': 'Não disponibilizado; guarda QA/flagfalse. Nenhuma escrita ou divulgação ativa autorizada.',
|
||
'Evidência funcional': evidence, 'Estado': 'parcial', 'Lacuna': gap, 'Dependência': 'Biblioteca/documentos existentes; Balcão+190000 para referência ao dossier; navegador para aceitação visual. Perfis/SLA são decisões independentes.',
|
||
'Critério de aceitação': acceptance, 'Prioridade': 'P1', 'Versões divergentes': 'Legado post171985 modificado2026-04-20; categoria Oportunidades preservada como metadado, entidade guia. Metadados/sitemapInfopublica e área de investimento distintos; não remapear por nome.',
|
||
'IDs das linhas de origem': origin, 'Quantidade de linhas de origem': '1',
|
||
'Critérios e versões por origem (JSON)': json.dumps([{'id': origin, 'pedido': request, 'fontes': ['Pedido direto10/10/2026 §' + str(int(suffix))], 'aceitacao': acceptance, 'estado': 'parcial', 'variantes': 'IntegraçãoprogressivaQA, sem substituição de pendências anteriores', 'modulo': 'legado/biblioteca/percurso'}], ensure_ascii=False),
|
||
'Interface': 'React preparado e buildQA; dados Inertia recuperados. Navegador indisponível; roteiro manual em legado-piloto-qa.md. Aceitação visual pendente.',
|
||
'API': 'GET públicos e escritas autenticadas no servidor18097, alvo real55441 confirmado; publicação separada do importador.',
|
||
'Gravação': 'Dois recursos na biblioteca existente e referências no payload cifrado do dossier; sem nova tabela/cadastro paralelo.',
|
||
'Recuperação': 'Logout/nova sessão FortifyMFA21: fonte/versão/hashes recuperados; outra conta negada e projeto independente sem referência.',
|
||
'Resultado apresentado': 'Dados retornam ao artigo/dossier/consumidores; render/cliques ainda não observados.',
|
||
'Consolidação': 'Novo pedido verificável incorporado à matriz existente; não duplica nem encerra requisitos históricos.'})
|
||
rows.append(row)
|
||
for row in rows:
|
||
if row['ID'] in ['AUD-LIB-01', 'AUD-LIB-02', 'AUD-DOC-01']:
|
||
for key, value in {'Evidência funcional': evidence, 'Lacuna': 'Legado:50 versões observadas e sitemap30.170 entradas inventariados; cobertura histórica não encerrada. Visual/C37 pendentes.'}.items():
|
||
if tag not in row[key]: row[key] += ' | ' + tag + value
|
||
if row['ID'] in ['AUD-OP-04', 'AUD-PROFILE-28']:
|
||
modular = '[Experiência modular QA 10/10/2026] '
|
||
if modular not in row['Evidência funcional']:
|
||
row['Evidência funcional'] += ' | ' + modular + 'QA42–48:sete modalidades dos quatro módulos percorrem rascunho→submissão→correção→revisão/gates→aprovação→divulgação restrita e nova sessãoMFA. QA49 entidade semexercícios com declaração/projeções identificadas. Recibos opportunity-modular-http-qa.json e opportunity-history-http-qa.json; C67 projeção de resumo corrigida. Extensões explicitamenteQA; legados36–39 preservados. Visual, materialidade, Response normativo e aprovação funcional/compromissosSLA pendentes; confirmação do legado não aprova esses pontos.'
|
||
assert before == {r['ID']: {k: r[k] for k in protected} for r in rows if not r['ID'].startswith('AUD-LEG-')}
|
||
assert len({r['ID'] for r in rows}) == len(rows)
|
||
with matrix.open('w', encoding='utf-8-sig', newline='') as f:
|
||
w = csv.DictWriter(f, fieldnames=headers); w.writeheader(); w.writerows(rows)
|
||
counts = {'requirements': len(rows), 'matrix_rows': len(rows), 'source_occurrences': sum(int(r['Quantidade de linhas de origem']) for r in rows), 'states': dict(collections.Counter(r['Estado'] for r in rows))}
|
||
(PRIVATE / 'legacy-audit-update-qa.json').write_text(json.dumps(counts, ensure_ascii=False, indent=2), encoding='utf-8')
|
||
|
||
marker = 'qa-legacy-20261010'
|
||
def prepend(name, content):
|
||
import re
|
||
path = DOC / name
|
||
text = path.read_text(encoding='utf-8-sig')
|
||
text = re.sub(r'<!-- ' + marker + r':begin -->.*?<!-- ' + marker + r':end -->\s*', '', text, flags=re.S)
|
||
first, _, rest = text.partition('\n')
|
||
replacements = {'exclusivamenteQA': 'exclusivamente em QA', 'um sitemap de outro domínio': 'um sitemap de outro domínio', 'Perfismodulares/SLA': 'Perfis modulares/SLA',
|
||
'Legado:50': 'Legado: 50', 'ClamD real nos dois': 'ClamD real nos dois', 'imagemhistórica404': 'imagem histórica 404',
|
||
'I0–I3/C37': 'I0–I3/C37', 'comPromotorQA21': 'com Promotor QA21', 'LEG07': 'LEG07', 'porperfil': 'por perfil',
|
||
'links não recolhidos antesde': 'links não recolhidos antes de', 'imagemlegada': 'imagem legada',
|
||
'original recuperado comfundamentoou': 'original recuperado com fundamento ou', 'artigosconsumidores': 'artigos consumidores',
|
||
'visual/materialidade/Response/aprovaçãoformal': 'visual/materialidade/Response/aprovação formal',
|
||
'50 exemplares': '50 exemplares', 'perfilmodular': 'perfil modular', 'folgaQA': 'folga QA',
|
||
'173lacunasgeo': '173 lacunas geo', '(Mandie/Guroambíguo)': '(Mandie/Guro ambíguo)', '030000→040000→190000conservado': '030000 → 040000 → 190000 conservado',
|
||
'recursoslegado/cartografia/divulgação': 'recursos do legado/cartografia/divulgação', 'revisãocandidata/config/bundle': 'revisão candidata/config/bundle',
|
||
'backup/restauroda baseativa correspondente': 'backup/restauro da base ativa correspondente', 'rasculhosprivados': 'rascunhos privados',
|
||
'hash/idjáexistirem': 'hash/ID já existirem', 'conflitosaguardamrevisão': 'conflitos aguardam revisão', 'Cartografiaéoutrolote, nãoautopublicado': 'Cartografia é outro lote, sem publicação automática',
|
||
'ocultarflag/bundlecompatível': 'ocultar a flag/usar bundle compatível', 'originais, versões, fontes dosdossiers': 'originais, versões, fontes dos dossiers',
|
||
'baseativa+candidata': 'base ativa + candidata', 'Decisõesperfis/SLA,C37,Promotor21visual,A/Bcontinuamabertas': 'Decisões de perfis/SLA, C37, Promotor21 visual e A/B continuam abertas',
|
||
'ClamD real/hash': 'ClamD real/hash', 'doexemplar doartigoJSONno importadorexistente': 'do exemplar do artigo JSON no importador existente',
|
||
'MIMEapplication/json': 'MIME application/json', 'uploadJSONguardadoQA': 'upload JSON guardado em QA',
|
||
'originalpreservado': 'original preservado', 'hashverificado': 'hash verificado', 'publicaçãoautenticada': 'publicação autenticada',
|
||
'Doisrecursos; repetição semduplicação': 'Dois recursos; repetição sem duplicação', 'imagem original; observado404': 'imagem original; observado 404',
|
||
'Identificado noinventário': 'Identificado no inventário', 'PNGdoagronegócio': 'PNG do agronegócio', 'emartigosconsumidores dessaimagem': 'em artigos consumidores dessa imagem',
|
||
'summary`de500carateres': 'summary` de 500 carateres', 'a499+reticência': 'a 499 + reticência',
|
||
'depoisQA42–48divulgadas/recuperadas noutraMFA. Visualpendente': 'depois QA42–48 divulgadas/recuperadas noutra sessão MFA. Visual pendente',
|
||
'conforme autorizaçãoexistente; gestorQA14executou': 'conforme autorização existente; gestor QA14 executou',
|
||
'C37nãofoiretestadonemencerrado': 'C37 não foi retestado nem encerrado'}
|
||
for source, replacement in replacements.items(): content = content.replace(source, replacement)
|
||
path.write_text(first + '\n\n<!-- ' + marker + ':begin -->\n' + content + '\n<!-- ' + marker + ':end -->\n\n' + rest.lstrip(), encoding='utf-8')
|
||
|
||
summary = f'''## Frente atual — legado agronegócio, exclusivamenteQA
|
||
|
||
Piloto: **http://127.0.0.1:18097/explorar/agronegocio → /agronegocio → /investimentos/a-partir-de/agronegocio**. Artigo6 e imagem5 reutilizam biblioteca; leitura/pesquisa/imagem públicas, gravação privada autenticada. HTTP/persistência demonstrados: criação e continuação, logout/novaMFA21, recuperação, replay, isolamento e retorno com contexto. ClamD real nos dois originais. Uma oportunidade publicada no setor permanece independente; nenhuma oportunidade/geo inferida do artigo.
|
||
|
||
Inventário: **92URLs**, **50 versões de artigos**, **2.826 referências**, **30.170 entradas anunciadas num sitemap de outro domínio**, sem afirmar recolha integral. Dois recursos importados; repetição sem duplicação. Fonte/autoria/datas/versão/hash e diferenças preservadas. C68:imagemhistórica404. Cobertura documental histórica permanece aberta.
|
||
|
||
Mesma matriz: **{counts['requirements']} requisitos únicos = {counts['matrix_rows']} linhas lógicas; {counts['source_occurrences']} ocorrências de origem**. Estados: ''' + '; '.join(f'{n} {state}' for state, n in counts['states'].items()) + '''. Sete pedidos novosLEG01–07 incorporados, todos parciais. Nenhum critério anterior foi encerrado por esta frente.
|
||
|
||
Referências visuais reais: CSS/chunkwelcome, fotografia original, Noto Sans/Serif locais no buildQA, paleta/cartões adaptados. **Navegador integrado indisponível; aceitação visual pendente**, com roteiro executável. C37/QA18, Promotor21 visual e A/B continuam abertos. Perfismodulares/SLA têm decisões independentes; a confirmação do legado não os aprova.
|
||
|
||
Relatório e critérios: [legado-piloto-qa.md](legado-piloto-qa.md); inventários `legado-inventario.csv`, `legado-conteudos-reconciliados.csv`, `legado-referencias.csv`, `legado-sitemap-anunciado.csv`. Recibos privados sanitizados por percurso. Ativo não alterado; candidata ainda não fixada. Blocos anteriores são evidências históricas datadas, não conclusão global atual. Merece distinção o ensaio modularQA42–49 já demonstrado porHTTP, comvisual/materialidade/Response/aprovaçãoformal pendentes.'''
|
||
prepend('relatorio-executivo.md', summary)
|
||
prepend('backlog.md', '''## Próximo lote — expansão do legado sem substituirI0–I3/C37
|
||
|
||
| Prioridade / requisito | Próxima ação concreta | Critério de fecho |
|
||
| --- | --- | --- |
|
||
| P1 / AUD-LEG-02/03/04/06/07 | Roteiro visual do piloto comPromotorQA21: leitura anónima, pesquisa, fonte, criação/continuação, nova sessão, retorno e móvel/teclado. | Ações observadas por perfil/consumidor; mesmosIDs/dados, recuperação/negações e resultado visível. C37 continua independente. |
|
||
| P1 / AUD-LEG-01/05; LIB01/02; DOC01 | Reconciliar os50 exemplares observados, paginação/URLs externos e sitemap misto; importar apenas próximo lote com hash/versão/scan e divulgação separada. | Inventário/deduplicação/citações e destinos explícitos; sem omitir nãoimportados nem publicar automaticamente. |
|
||
| P1 / AUD-LEG-03/05; GEO | Conferir seis nomes/âmbitos de corredores com cadastros/publicações existentes. | Relação territorial inequívoca com citação/IDs; geometria disponível distinguida de uso visual. Nada de inferir parcelas ou coberturas pelo nome. |
|
||
| P1 / AUD-LEG-07 / C68 | Rever imagemlegada GOVERNADOR-in.gif404 e outroslinks não recolhidos antesde expandir esses artigos. | Original recuperado comfundamentoou ausência identificada; não declarar ficheiro inexistente como válido. |
|
||
| P1 / OP04 / PROFILE28 | Continuar Response integral/materialidade e cerimónia funcional; quatro módulosQA42–48 e nohistóricoQA49 têm provaHTTP, não fecho visual/formal. | Critérios especializados por categoria; revisões afetadas, decisões/external/publicação distintas. Confirmação legado não aprova perfis/SLA. |
|
||
|
||
Países/origem, estatísticas,173lacunasgeo (Mandie/Guroambíguo), documentos por consumidor, recuperação e acessos/workflow restantes permanecem no backlog anterior. Próximo trabalho executável não depende de designar pessoas reais ou aprovar compromissosSLA. Plano global030000→040000→190000conservado; recursoslegado/cartografia/divulgação separados. Ver `legado-piloto-qa.md`.''')
|
||
prepend('plano-ativacao.md', '''## Complemento legado — preparaçãoQA, sem autorização ativa
|
||
|
||
Preservar **030000→040000→190000**, verificar ledger/schema/dependências no alvo antesde aplicar; não repetir instalações. Leitura editorial usa a biblioteca existente; associação ao dossier exige Balcão+190000. Perfismodulares não são pré-condição da leitura pública.
|
||
|
||
Ordem deste complemento: demonstração visualQA/correções→revisãocandidata/config/bundle→backup/restauroda baseativa correspondente→upload/importação autenticados como rasculhosprivados→hash/duplicados/ClamD/metadados→associações fundamentadas→divulgação explícita por recurso→disponibilização pública/percurso. O CLI importador sóaplicaQA; a guarda atual do piloto impede ativo, mesmo comflagtrue. Habilitação ativa requer revisão/autorização própria.
|
||
|
||
Contagem prevista: **1artigo+1imagem**, **3associações**, **0oportunidades ou geometrias criadaspeloartigo**, **0contas/dossiersQAtransferidos**; noopssehash/idjáexistirem, conflitosaguardamrevisão. Cartografiaéoutrolote, nãoautopublicado. Reversão:ocultarflag/bundlecompatível e retirar divulgação auditada; preservar originais, versões, fontes dosdossiers e histórico. BackupQA não substitui prova da baseativa+candidata. Decisõesperfis/SLA,C37,Promotor21visual,A/Bcontinuamabertas.''')
|
||
prepend('casos-reproducao.md', '''## Casos adicionais preservados — fontes e consumidor novo
|
||
|
||
- **C67 / OP04:** publicar a chamada de concessãoQA42 após aprovação. Esperado: projeção restrita persistida e legível. Observado inicial:HTTP503; rótulo de modalidade adicionado à síntese excedia coluna `summary`de500carateres. Causa no novo adaptador, não no middleware. Correção limita projeção a499+reticência e preserva texto canónico completo. Tentativa falhada arquivada; depoisQA42–48divulgadas/recuperadas noutraMFA. Visualpendente.
|
||
- **C68 / LEG07:** GET público legado `/images/ANO-2017/JUNHO/21/GOVERNADOR-in.gif`. Esperado: imagem original; observado404. Identificado noinventário; não afeta PNGdoagronegócio. Falha permanece emartigosconsumidores dessaimagem; não inventar substituição ou autenticidade.
|
||
- **C69 / LIB02 / LEG01:** prévia doexemplar doartigoJSONno importadorexistente. Esperado: cadastro privado, scan e adaptação segura; observado MIMEapplication/json não suportado. Lista doimportador reconciliada e uploadJSONguardadoQA; originalpreservado, apresentação sanitizada, hashverificado, publicaçãoautenticada separada. Doisrecursos; repetição semduplicação. Não alargar divulgação ativa.
|
||
|
||
Superutilizador27 recebeu403 em gestão documental, conforme autorizaçãoexistente; gestorQA14executou divulgação. Esse ensaio negativo comprova segregação e não é uma falha a contornar. C37nãofoiretestadonemencerrado.''')
|
||
|
||
proposal = DOC / 'propostas-perfis-modulares-sla.md'
|
||
text = proposal.read_text(encoding='utf-8')
|
||
text = text.replace('a decisão QA seguinte tem precedência nos pontos identificados.', 'o ensaio técnico QA seguinte identifica as recomendações adotadas experimentalmente, sem decisão formal de ativação.')
|
||
text = text.replace('## Recomendações aceites para execução exclusivamente em QA — 10/10/2026', '## Recomendações ensaiadas tecnicamente em QA — decisões próprias pendentes')
|
||
text = text.replace('Esta decisão permite implementação e ensaios sintéticos; não declara os módulos completos nem aprova ativação.', 'A implementação e os ensaios sintéticos decorrem da autorização QA existente. As recomendações não são compromissos operacionais aprovados; a confirmação posterior sobre o legado refere-se somente ao legado e não decide perfis/SLA.')
|
||
proposal.write_text(text, encoding='utf-8')
|
||
sources = DOC / 'inventario-fontes.csv'
|
||
source_rows = list(csv.DictReader(sources.open(encoding='utf-8-sig')))
|
||
for path in [proposal, DOC / 'legado-piloto-qa.md', DOC / 'legado-inventario.csv', DOC / 'legado-conteudos-reconciliados.csv', DOC / 'legado-referencias.csv', DOC / 'legado-sitemap-anunciado.csv']:
|
||
data = path.read_bytes(); sha = hashlib.sha256(data).hexdigest()
|
||
record = next((r for r in source_rows if r['Exemplar/caminho'].replace('\\', '/').endswith('/' + path.name)), None)
|
||
if record is None:
|
||
record = {k: '' for k in source_rows[0]}; source_rows.append(record)
|
||
record['Exemplar/caminho'] = str(path)
|
||
elif record['SHA256'] and record['SHA256'] != sha:
|
||
record['Edição/versão identificada'] += ' | exemplar anterior SHA256=' + record['SHA256']
|
||
record.update({'Bytes': str(len(data)), 'SHA256': sha, 'Tipo': 'reconciliação auditoriaQA', 'Nota de leitura': 'Legado inventariado/pilotoQA; cobertura histórica e decisões separadas explicitamente preservadas.', 'Disponibilidade': 'Relatório/inventário para consulta; não habilita importação/publicação ativa', 'Uso em cálculo': 'Nenhum cálculo ativado', 'Situação jurídica/documental': 'Documento de programação/reconciliação, não fonte oficial', 'Edição/versão identificada': record['Edição/versão identificada'] + ' | atualizaçãoQA10/10/2026'})
|
||
with sources.open('w', encoding='utf-8-sig', newline='') as f:
|
||
w = csv.DictWriter(f, fieldnames=list(source_rows[0])); w.writeheader(); w.writerows(source_rows)
|
||
print(json.dumps(counts, ensure_ascii=True))
|