RPL (HP48 S/SX), 294.5 bytes
Yes, ridiculously large submission, not sure how competitive it will be...
DIR M « S ROT S SWAP ABS 4 PICK ABS DUP2 WHILE DUP2 REPEAT MOD SWAP END DROP2 SWAP OVER / 4 ROLL J 3 ROLLD / ROT J ROT 0 > IF THEN SWAP END + » S « DUP "^" POS DUP2 1 + OVER SIZE SUB OBJ🡢 3 ROLLD 1 - 1 SWAP SUB » J IF OVER 1 == THEN SWAP DROP ELSE DUP SIZE DUP2 DUP SUB "Z" > - 1 > IF THEN "(" SWAP + ")" + END "_" + SWAP + END END
3 routines packaged neatly in a directory. M is the main one. It expects 2 strings on the stack formatted as ions and pushes a molecule string onto the stack.
S splits the ion into charge as a number and the element formula as a string. For example, "PO_4^-3" would be taken from the stack and -3 and "PO_4" pushed onto the stack.
J joins the number of ions with the formula and decides whether to wrap the formula into brackets. The bit before ELSE deals with 1 ion, leaving the string as it is. For example, if 1 and "PO_4" are on the stack, they are replaced by "PO_4". 1 and "H" gives "H".
The rest deals with multiple ions; if it's a single atom it's not in brackets, otherwise it is. To decide whether it is, I check the length of the string and check if the last character is >"Z". Boolean expressions return 1 for true and 0 for false. By subtracting the result of this comparison from the length of the string, I get 1 or less when it's single atom, otherwise more: length 1 is a single atom; length 2 will have a letter as the last character; for a single atom it's lower case, so >"Z", making result 1, otherwise 2; length 3 or more means more than 1 atom and with 0 or 1 subtracted from the length the result will be at least 2. For example, 3 and "PO_4" gives "(PO_4)_3". 3 and "Al" gives "Al_3".
M first splits each ion using S. After the first line the level 5 of the stack (so the deepest buries object) contains charge of the second ion, level 4 second ion's formula, level 3 first ion's formula, level 2 absolute value of the first ion's charge and level 1 absolute value of the second ion's charge again. For example, if given ions on the stack are "Al^+3", "SO_4^-2", we get -2, "SO_4", "Al", 3, 2.
Second line calculates gcd of the 2 charges (leaving the charges intact).
Third line divides each charge by the gcd (to calculate the multiples) and joins it with the ion's formula using J. So we have two strings each with one given ion with charge removed (or a multiple of it) and a charge of the second one buried behind them. For example, -2, "Al_2", "(SO_4)_3" (-2 is the charge of SO_4).
Fourth line unburies the charge and if it's positive it swaps the two strings (so that the cation comes first) before joining them. So in example above, because it's negative, they are joined in the order as they are: "Al_2(SO_4)_3".
Fe^+2, OH^-1: Fe(OH)_2for a polyatomic ion with 1 of each element (OH^-1). \$\endgroup\$NO_3^-1. Also another test case should be the first one paired up with a^-2, so it would make(C(NH_2)_3)_2.... Or a case where the ion that is needed more than once begins with a bracket. \$\endgroup\$Fe_4(Fe(CN)_6)_3for Prussian blue. \$\endgroup\$